Sticky Sessions
Sticky sessions are an opt-in feature of the HTTP transport that lets an RPC method bind a server-side state object to the worker process that created it. Subsequent requests from the same client restore that object as ctx.session, so a method can keep a DB cursor, a loaded model, or an open file handle alive across calls.
The session is keyed by a short-lived, AEAD-sealed token carried in the VGI-Session request header. The non-sticky wire path is byte-identical to the pre-sticky framework — nothing changes unless you enable it.
What sticky sessions solve
Section titled “What sticky sessions solve”Without sticky sessions, every HTTP request is independent and any per-call state must be reconstructed from scratch (or carried in a stateless stream token). Sticky sessions are useful when:
- Re-establishing state per request is expensive (loading a model, opening a connection, warming a cache).
- The state cannot be serialized into a token (an open socket, a file handle, a live cursor).
- You need a sequence of related unary calls to share a server-side object.
Enabling sticky sessions
Section titled “Enabling sticky sessions”Pass enableSticky: true to createHttpHandler():
import { Protocol, int, createHttpHandler } from "@query-farm/vgi-rpc";
const protocol = new Protocol("Sessions");
const handler = createHttpHandler(protocol, { enableSticky: true, // opt-in (default: false) stickyDefaultTtl: 300, // default session TTL in seconds (default: 300) stickyEchoHeaders: { // headers echoed on the opening response (default: {}) "fly-force-instance-id": process.env.FLY_ALLOC_ID ?? "", },});
Bun.serve({ port: 8080, fetch: handler });| Option | Type | Default | Description |
|---|---|---|---|
enableSticky | boolean | false | Enable sticky sessions on this handler. |
stickyDefaultTtl | number | 300 | Session TTL in seconds used when ctx.openSession is called without an explicit ttl. |
stickyEchoHeaders | Record<string, string> | {} | Headers emitted as VGI-Echo-<name>: <value> on the session-opening response. See client-driven routing. |
When sticky is enabled, the server advertises the capability on every response via these headers:
VGI-Sticky-Enabled: trueVGI-Sticky-Default-TTL: <seconds>VGI-Sticky-Echo-Headers: <comma-separated names>(only whenstickyEchoHeadersis non-empty)
Sticky sessions are HTTP-only. The session API throws on other transports.
Opening and using a session in a handler
Section titled “Opening and using a session in a handler”The session API lives on the handler’s CallContext (ctx):
protocol.unary("open_counter", { params: { start: int }, result: { value: int }, handler: async ({ start }, ctx) => { // Bind server-side state to a new session. ctx.openSession({ count: start }); return { value: start }; },});
protocol.unary("increment", { params: {}, result: { value: int }, handler: async (_params, ctx) => { // Resume the state bound by a prior request on this session. const state = ctx.session as { count: number } | null; if (state === null) throw new Error("no session"); state.count += 1; return { value: state.count }; },});
protocol.unary("done", { params: {}, result: {}, handler: async (_params, ctx) => { ctx.closeSession(); return {}; },});Context API (CallContext in src/types.ts):
ctx.session: unknown | null— the live state object, ornullwhen no session is bound to this request.ctx.sessionId: string | null— opaque 24-char hex session id. SurvivescloseSession()so post-close access-log records still carry the id.ctx.openSession(state, ttl?)— registerstatefor subsequent requests.ttl(seconds) overridesstickyDefaultTtl. Throws if the client did not opt in (see below), if a session is already active for this request, or on a non-HTTP transport.ctx.closeSession()— invalidate the session bound to this request. Idempotent.
How a session is established and identified on the wire
Section titled “How a session is established and identified on the wire”The flow is client-driven and uses a small set of VGI-Session* headers.
- Client opts in. To allow a handler to open a session, the client must send
VGI-Session-Accept: trueon the request. Ifctx.openSession()is called without this header, it throws aRuntimeError(“client did not opt in to sticky sessions”). This prevents a server from minting sessions for clients that aren’t prepared to track them. - Server mints a token. When a handler calls
ctx.openSession(state), the server storesstatein the per-worker registry under a random 12-byte session id, seals a token, and emits it on the response asVGI-Session: <token>. IfstickyEchoHeadersis configured, those are also emitted on this opening response asVGI-Echo-<name>: <value>. - Client replays the token. On each subsequent request, the client sends
VGI-Session: <token>(the value it received). The server decrypts the token, looks up the registry entry, and exposes it asctx.session. - Server closes the session. When a handler calls
ctx.closeSession()(or the client issuesDELETE /__session__), the entry is removed,state.close?.()is invoked, and the response carriesVGI-Session-Close: true.
The token is sealed with the same XChaCha20-Poly1305 key (tokenKey) used for stream-state tokens, and is bound to the authenticated principal via the AEAD associated data. The plaintext frame embeds the serverId, the session id, and the absolute expires_at.
Session resolution failures
Section titled “Session resolution failures”If a request carries a VGI-Session token that cannot be honored, the server returns an HTTP 200 response carrying a SessionLostError (surfaced as an X-VGI-RPC-Error EXCEPTION batch). This happens when:
- The token is malformed, was sealed with a different key, or is bound to a different principal (cross-principal replay).
- The token’s embedded
serverIddoes not match this worker (“session token was issued by a different worker”). - The registry entry is missing, expired, or evicted (“session not found, expired, or principal mismatch”).
A conformant client treats SessionLostError as a signal to re-open the session.
Session lifetime and TTL
Section titled “Session lifetime and TTL”Each session has an absolute expiry computed at open time: now + (ttl ?? stickyDefaultTtl) seconds. The default stickyDefaultTtl is 300 seconds; override per session via the second argument to ctx.openSession(state, ttl).
Expiry is enforced two ways:
- Lazy — a resume that lands on an expired entry evicts it (calling
state.close?.()) and reports a miss. - Background reaper — a periodic timer (roughly once per second) sweeps and evicts every expired entry. The timer is
unref’d so it does not keep the process alive.
Note the TTL is not refreshed on resume — a session expires ttl seconds after it was opened regardless of activity. To extend a session, open a fresh one.
Tearing down a session: DELETE /__session__
Section titled “Tearing down a session: DELETE /__session__”When sticky is enabled, the handler exposes a teardown route at DELETE {prefix}/__session__. The client sends the session token in the VGI-Session header. This is the explicit, client-initiated counterpart to a handler calling ctx.closeSession().
curl -X DELETE http://localhost:8080/__session__ \ -H "VGI-Session: <token>"The endpoint is deliberately idempotent and non-probing:
- A missing token, a malformed/forged token, a
serverIdmismatch, or a registry miss all return HTTP 200 with no body — so the endpoint cannot be used to discover which session ids are live. - A successful close returns HTTP 204 with
VGI-Session-Close: true.
If an authenticate callback is configured, the teardown path runs it so the principal binding matches the dispatch flow; auth failures are treated as anonymous and fall through to the idempotent 200.
Client-driven routing with echo headers
Section titled “Client-driven routing with echo headers”Because session state is pinned to a single worker, a load balancer must route resume requests back to the worker that minted the token. stickyEchoHeaders supports this:
- The server is configured with
stickyEchoHeaders, e.g.{ "fly-force-instance-id": "<this-instance>" }. - On the session-opening response, the server emits each as
VGI-Echo-<name>: <value>(here,VGI-Echo-fly-force-instance-id). It also advertises the captured names inVGI-Sticky-Echo-Headers. - A conformant client captures every
VGI-Echo-<name>and replays the corresponding<name>: <value>header on every subsequent request in the session. - The platform’s router (e.g. Fly.io’s
fly-force-instance-id) uses that header to pin the request to the originating instance.
Echo headers are emitted only on the opening response, since that is when the client learns which worker to stick to.
Client-side considerations
Section titled “Client-side considerations”- Send
VGI-Session-Accept: trueon any request where the server might open a session; otherwisectx.openSession()fails. - Capture the
VGI-Sessiontoken from the opening response and replay it on every subsequent request, until you receiveVGI-Session-Close: true(or issueDELETE /__session__). - Capture any
VGI-Echo-<name>headers from the opening response and replay the underlying<name>headers for the life of the session so the load balancer pins you to the right worker. - Treat a
SessionLostErroras “the session is gone” — re-open rather than retrying blindly. - The handler exposes a
DrainHandle(via the internal_onStickyHandlehook) so operators candrain()(reject new sessions while letting existing ones finish) andshutdown()(close every live session) during graceful shutdown.
See also
Section titled “See also”- HTTP Transport — routes, configuration, and stateless streaming.