Thread Safety
Thread Safety
Note — As of 2026-04-30, the per-service
@cuilabs/qnsp-vault-sdkpackage is consolidated into the unified@cuilabs/qnspSDK (one package per language). New integrations should use:import { QnspClient } from "@cuilabs/qnsp"; const qnsp = new QnspClient({ apiKey: process.env.QNSP_API_KEY! }); await qnsp.vault./* method */(...);See SDK overview for the consolidated package. The per-service shapes documented below remain accurate at the wire level (REST/gRPC) and are kept for reference.
Thread Safety
QNSP ships SDKs in five languages: TypeScript/Node.js, Python, Go, Rust, and JVM/Android. Each language has its own concurrency model — the guarantees below describe what each official SDK gives you.
Node.js / TypeScript
- Single-threaded event loop; the SDKs are safe for concurrent
asyncoperations from the same client instance. - One client instance per application is the recommended pattern.
import { VaultClient } from "@cuilabs/qnsp-vault-sdk";
// Good: shared client
const client = new VaultClient({ baseUrl: "https://api.qnsp.cuilabs.io/proxy/vault", apiKey: "<token>" });
async function handler1() { await client.createSecret({ tenantId: "<uuid>", name: "s1", payload: "<base64>" }); }
async function handler2() { await client.createSecret({ tenantId: "<uuid>", name: "s2", payload: "<base64>" }); }
// Bad: new client per request — wastes connections
async function handler() {
const client = new VaultClient({ baseUrl: "https://api.qnsp.cuilabs.io/proxy/vault", apiKey: "<token>" });
await client.createSecret({ tenantId: "<uuid>", name: "s", payload: "<base64>" });
}
Python (qnsp v0.2.0+)
QnspClientis not thread-safe by default. The internal activation cache andhttpx.Clientconnection pool are shared by all sub-clients (vault,kms,audit); concurrent calls from multiple threads are safe at the HTTP layer (httpx.Clientis thread-safe), but cache invalidation is not synchronised.- Recommended pattern: one
QnspClientper process, served via a global or viacontextvarsfor per-request scoping. - For multi-process workloads (gunicorn, uvicorn workers), construct one client per worker after the fork.
from qnsp import QnspClient
qnsp = QnspClient(api_key=os.environ["QNSP_API_KEY"]) # process-wide singleton
async def handle():
await asyncio.to_thread(qnsp.vault.create_secret, name="s", payload_b64="...")
A native-async variant (AsyncQnspClient over httpx.AsyncClient) is on the v0.3.0 roadmap.
Go (github.com/cuilabs/qnsp-public/sdks/go/qnsp v0.1.0+)
qnsp.Clientis safe for concurrent use by multiple goroutines. The internal*Activatoruses async.Mutexaround the activation cache;*http.Clientis goroutine-safe by Go's standard library guarantees.- Recommended pattern: one
qnsp.Clientper program, passed to handlers / workers.
c, _ := qnsp.NewClient(qnsp.ClientOptions{APIKey: os.Getenv("QNSP_API_KEY")})
defer c.Close()
go func() { c.Vault().CreateSecret(ctx, vault.CreateSecretRequest{...}, "") }()
go func() { c.KMS().Sign(ctx, "key-id", []byte("hello"), "") }()
Rust (qnsp v0.1.0+)
qnsp::ClientisClone+Send+Sync. Internal state lives behindArc<Activation>; the activation cache usesstd::sync::Mutex.reqwest::Clientis itself a cheapCloneover anArc<Inner>.- Recommended pattern: build one
qnsp::Clientat startup,clone()it freely (cheap), and pass clones into spawned tasks.
let c = qnsp::Client::new(opts)?;
let c2 = c.clone();
tokio::spawn(async move { c2.vault().create_secret(req, None).await });
JVM / Android (io.cuilabs:qnsp v0.1.0+)
QnspClientis thread-safe and built to be shared. It owns one OkHttpOkHttpClient(an internally pooled, thread-safe HTTP client) and one activation cache guarded by asynchronizedblock.- Recommended pattern: construct one
QnspClientat startup (e.g. a Spring singleton@Bean) and inject it everywhere.OkHttpClientis explicitly designed to be shared across threads.
val qnsp = QnspClient(System.getenv("QNSP_API_KEY"))
// share the single instance across threads / coroutines:
executor.submit { qnsp.vault.createSecret(req) }
Connection pooling
All SDKs reuse a single underlying HTTP connection pool per client instance:
- TypeScript: native
fetch(Undici under Node) reuses keep-alive connections. - Python:
httpx.Clientkeeps a connection pool per host. - Go:
*http.Clientreuses connections via the defaultTransport. - Rust:
reqwest::Clientkeeps a connection pool per host (Hyper underneath). - JVM/Android:
OkHttpClientkeeps a shared, thread-safe connection pool per client instance.
Construct one client and reuse it; do not build a fresh client per request.
Token refresh synchronisation
Each SDK runs the activation handshake on first use and caches the result with a near-expiry buffer (60 seconds before the server-issued expiresAt). On a 401, the cache is invalidated and the originating request retried once.
In all SDKs the refresh is not strictly serialised across concurrent callers — concurrent goroutines / threads / async tasks may both observe a refresh in flight. This is intentional: the activation endpoint is idempotent, the response is identical, and serialising would block on lock contention. If you observe duplicate handshakes in your load tests, that is the expected behaviour.
Cleanup
- TypeScript: SDK clients do not require explicit cleanup.
- Python:
QnspClientis a context manager; usewith QnspClient(...) as q:or call.close()to release thehttpx.Clientconnection pool. - Go: call
Client.Close()(currently a no-op but reserved). - Rust:
Dropreleases theArc-shared state automatically. - JVM/Android: no explicit cleanup required; the underlying OkHttp connection pool idles out automatically.