Skip to content

feat: add refuse() calculator and session.complete()#5

Open
damusix wants to merge 2 commits intomainfrom
feat/refuse-204
Open

feat: add refuse() calculator and session.complete()#5
damusix wants to merge 2 commits intomainfrom
feat/refuse-204

Conversation

@damusix
Copy link
Copy Markdown
Contributor

@damusix damusix commented Apr 25, 2026

Closes #4.

Summary

Two primitives for spec-compliant HTTP 204 No Content responses, each addressing a different use case.

refuse (subscription config)

A predicate that runs before the session is created. Server-state checks like shutdown, overload, or open circuit breakers. Returning true responds with 204; the EventSource stops reconnecting.

server.sse.subscription('/events', {
    refuse: (request) =>
        server.app.shuttingDown || circuitBreaker.isOpen(),
});

Sync or async. Throwing a Boom error returns that HTTP error.

session.complete() (session method)

A mid-stream method for when the stream's work is done. Writes a final event of type complete with session.id (a UUID generated at construction) as the SSE id field, records the id in a hapi server cache, then closes the connection. When the EventSource reconnects with that id in Last-Event-ID, the plugin responds 204.

stream: async (request, session) => {
    for await (const update of jobProgress(request.params.id)) {
        session.push(update, 'progress', update.eventId);
    }
    await session.complete();
}

Why two primitives

The WHATWG SSE spec says only that a compliant client must not reconnect after receiving 204. It doesn't define when or why a server should send one. The semantics are open to interpretation, and there's no established pattern across implementations.

Looking at how 204 is used in the wild, two distinct patterns emerge:

  • Server-state refusal at connection time — the server can't or won't open a stream right now. Examples: graceful shutdown, overload, open circuit breakers, dependency outage.
  • Stream completion mid-stream — the work this stream represents is done. Industry write-ups describe this as the dominant case: "server-side termination" when no more data will be sent, "stream maintenance" after a config has been applied (an IBM example walks through exactly this), and "resource optimization" when the server wants the client to stop polling.

The Quarkus discussion at quarkusio/quarkus#22762 lands on a similar reading: 204 is semantically a "completion" signal, and the moment the server decides to send it is usually mid-stream rather than at connection setup.

I checked Go, Python, and Rust SSE libraries while researching. None ship a first-class 204 API; every one expects the application to write 204 at the route level when needed. The call sites split cleanly into the two cases above. Two distinct primitives is this plugin's interpretation:

  • refuse is a config predicate, evaluated per request before the session exists.
  • session.complete() is a method on a live session, called when the work it represents is finished.

How completion works internally

Each Session gets a UUID at construction (exposed as session.id). When complete() runs, the plugin's hapi server cache records the id. On reconnect, the plugin checks Last-Event-ID against the cache and responds 204 if matched. Tokens are consumed on first redemption and otherwise expire by TTL.

The cache lives on the plugin's realm (server.realm.plugins['@hapi/sse']), so the Session reaches it via this.request.route.realm.plugins['@hapi/sse']. No callback wiring.

Configurability

The completion plugin option accepts the relevant subset of server.cache() options:

await server.register({
    plugin: SsePlugin,
    options: {
        completion: {
            cache: 'redis-cache',                 // optional: named cache engine
            segment: 'completed-sse-sessions',
            expiresIn: 5 * 60 * 1000,
        },
    },
});

Default: hapi's in-process cache with a 5 minute TTL.

The override matters under horizontal scale. Behind a load balancer, a client whose session was completed on server A might reconnect to server B. With the in-process default, server B has no record of the completion token and would let the connection through. Pointing cache at a shared engine like redis (configured at server creation via Hapi.server({ cache: [...] })) lets every node behind the LB honor a completion regardless of where it originated.

Test plan

  • Unit: complete() writes a final event, records session.id in the realm completion store, closes
  • Unit: complete() is a no-op when never initialized or already closed
  • Integration: refuse returning true responds 204 before any session is created
  • Integration: refuse returning false allows the connection through
  • Integration: refuse is awaited and receives the request
  • Integration: complete() causes 204 on the next reconnect via Last-Event-ID; tokens are consumed
  • Integration: completion cache override works with a named @hapi/catbox-memory engine
  • Integration: real EventSource client reconnects after session.close() (validates the docs claim)
  • Integration: real EventSource client stops reconnecting after session.complete() (validates spec compliance end-to-end)

Adds two primitives for spec-compliant 204 responses.

refuse: subscription config predicate that runs before session
creation. Returning true responds with 204.

session.complete(): mid-stream method that writes a final event
with session.id, records the id in a hapi server cache, then
closes. The next reconnect with that id in Last-Event-ID gets
204.

Completion cache is configurable via the completion plugin option
(cache, segment, expiresIn). Default: hapi's in-process cache,
5 minute TTL.
@damusix damusix changed the title feat: add refuse calculator and session.complete() feat: add refuse() calculator and session.complete() Apr 25, 2026
- Drop unreachable defensive check in #onKeepAlive: the keep-alive
  timer is cleared in close() before #closed flips, so the callback
  cannot run after close.
- Extract readLastEventId helper and reuse it in the subscription
  handler so the existing array-header unit test covers the parsing
  branch.
- Add a test for session.close() called from onSubscribe (the
  remaining path that hits the !session.isOpen short-circuit before
  initialize).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for HTTP 204 to avoid reconnections

1 participant