Thread Safety
Thread Safety
Note - As of 2026-04-30, the per-service
@heossihq/qnsi-vault-sdkpackage is consolidated into the unified@heossihq/qnsiSDK (one package per language). New integrations should use:import { QnsiClient } from "@heossihq/qnsi"; const qnsi = new QnsiClient({ apiKey: process.env.QNSI_API_KEY! }); await qnsi.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
QNSI 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 "@heossihq/qnsi";
// Good: shared client
const client = new VaultClient({ baseUrl: "https://api.qnsi.heossi.com/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.qnsi.heossi.com/proxy/vault", apiKey: "<token>" });
await client.createSecret({ tenantId: "<uuid>", name: "s", payload: "<base64>" });
}
Python (qnsi v0.4.2+)
QnsiClientis 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
QnsiClientper 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 qnsi import QnsiClient
qnsi = QnsiClient(api_key=os.environ["QNSI_API_KEY"]) # process-wide singleton
async def handle():
await asyncio.to_thread(qnsi.vault.create_secret, name="s", payload_b64="...")
For async applications, keep synchronous client calls outside the event-loop thread unless the specific SDK surface exposes a native async client. Do not rely on an old roadmap version when choosing concurrency behavior.
Go (github.com/heossihq/qnsi-public/sdks/go/qnsi)
qnsi.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
qnsi.Clientper program, passed to handlers / workers.
c, _ := qnsp.NewClient(qnsp.ClientOptions{APIKey: os.Getenv("QNSI_API_KEY")})
defer c.Close()
go func() { c.Vault().CreateSecret(ctx, vault.CreateSecretRequest{...}, "") }()
go func() { c.KMS().Sign(ctx, "key-id", []byte("hello"), "") }()
Rust (qnsi v0.3.0+)
qnsi::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
qnsi::Clientat startup,clone()it freely (cheap), and pass clones into spawned tasks.
let c = qnsi::Client::new(opts)?;
let c2 = c.clone();
tokio::spawn(async move { c2.vault().create_secret(req, None).await });
JVM / Android (com.heossi:qnsi v0.4.0+)
QnsiClientis 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
QnsiClientat startup (e.g. a Spring singleton@Bean) and inject it everywhere.OkHttpClientis explicitly designed to be shared across threads.
val qnsi = QnsiClient(System.getenv("QNSI_API_KEY"))
// share the single instance across threads / coroutines:
executor.submit { qnsi.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:
QnsiClientis a context manager; usewith QnsiClient(...) 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.