SSE Resume & Retry Policy — adapters/nethttp, api/rest¶
Status: Findings only — no proposal, no driver yet. Spun out from SSE Client Consumption's "Out of scope (Phase 2)" once both sub-features turned out to be genuinely CLIENT+SERVER, not client-only follow-ons — too large a scope to keep as a two-bullet aside in that doc (since shipped and removed; see
docs/design/d-0001-rest-middleware-workflow-simplification.md's addendum for the design lineage). ← Back to RoadmapThis doc captures ONLY what was confirmed via code inspection — no design decisions have been made, unlike SSE Client Consumption's fully-resolved (and since shipped) state. Treat every section below as "here is the starting point," not "here is the plan," same convention that doc itself used before its own resolution.
Why this exists¶
SSE Client Consumption originally deferred
two items to "Phase 2" as small, client-side-only asides: Last-Event-ID
resume, and a pluggable retry policy. Investigating what either would
actually require revealed BOTH are genuinely bigger, and genuinely
CLIENT+SERVER, not client-only:
- Confirmed via code (
adapters/nethttp/serve_sse.go,adapters/chi/serve_sse.go,adapters/nethttp/adapter.go,adapters/chi/adapter.go): the ONLY SSE protocol line ever written by this codebase's SSE writers, anywhere, isdata: %s\n\n— the standard SSE fieldsid:,event:, andretry:are NEVER emitted. Resuming from aLast-Event-IDtherefore needs NEW server-side machinery (anid:per event, plus something to replay from), not just a client-side reconnect header. - Confirmed via code (
stream/broadcast.go'sBroadcastHub): a newly-Subscribed consumer only ever receives events emitted AFTER it subscribes — there is NO buffer of past events anywhere. A server-side Last-Event-ID replay mechanism does not exist today in any form.
Confirmed current state (via code inspection, not assumption)¶
- No
id:/event:/retry:SSE protocol fields are ever written. Grepped every SSE writer in bothadapters/nethttpandadapters/chi— each one writes exactlyfmt.Fprintf(sw, "data: %s\n\n", data)and nothing else. A standards-compliant browserEventSourceclient already tracks the LAST receivedid:automatically and resends it as aLast-Event-IDrequest header on its own automatic reconnect — but since noid:is ever sent, this built-in browser behavior is currently a no-op against any go-codex SSE server. stream.BroadcastHubhas no past-event buffer.Subscribe()registers a new subscriber and returns aStream[T]that only receives values the hub forwards AFTER that call — confirmed via readingNewBroadcastHub/Subscribe's implementation instream/broadcast.go. There is no ring buffer, no persisted history, nothing to replay from.- The shipped
Consume/CallSSEAdapterdesign (as resolved) has a FIXED exponential backoff (ConsumeOptions.MaxBackoff, 250ms initial step, doubling, capped) — no caller-supplied policy function, no distinction between pre-first-event and post-first-event failure counts, no give-up threshold. docs/roadmap/webhook-adapter.md's own "Retry policy shape" open decision already sketches an IDENTICAL-SHAPE idea for a different context:RetryPolicy func(attempt int, err error) (retry bool, wait time.Duration)for webhook OUTBOUND delivery retries (finite, give-up-eventually) — worth cross-referencing as a shape precedent, NOT assuming the same implementation serves both (webhook retries are finite/give-up; SSE reconnection is naturally infinite/never-give-up today).
Scope sketch (two independent sub-features — not required to ship together)¶
A. Last-Event-ID resume¶
- Server: assign each emitted event an ID (monotonic counter? caller-
supplied per-event ID from the domain event itself? both are plausible,
not yet decided), write it as an
id: <id>\nline beforedata:, maintain a bounded in-memory buffer of the N most recent (id, encoded event) pairs perBroadcastHub, and — when a NEW connection arrives carrying aLast-Event-IDrequest header — replay every buffered event after that ID before switching to live forwarding. - Client:
Consume/CallSSEAdaptertracks the lastid:line seen from ANY event, and sends it as aLast-Event-IDrequest header on every (re)connect attempt (not just the first) — mirrors how a browserEventSourcealready behaves natively, but go-codex's OWNConsumeloop needs to do this explicitly (it is notnet/http-native behavior). - Both directions require extending whatever parses/writes a raw SSE
event block to handle MULTIPLE lines per event (
id:,data:, and potentiallyevent:/retry:— see "Out of scope" below for theevent:field specifically) — today's writer/reader design (as resolved) only ever handles a singledata:line per event block. This is a bigger parsing-layer change than either doc originally assumed.
B. Pluggable retry policy — two DISTINCT halves, not one shared mechanism¶
- Client half (go-codex's own
Consume/CallSSEAdapter): a caller-suppliedRetryPolicy func(attempt int, err error) (retry bool, wait time.Duration)replacing the fixed backoff — lets a caller do jittered backoff, per-error-type policies (e.g. give up immediately on a 404, retry forever on a network timeout), or a give-up-after-N threshold — this doc's own follow-on to that design's Open Design Decision 3). - Server half: emit the standard SSE
retry: <milliseconds>\n\nfield, which spec-compliantEventSourcebrowser clients honor NATIVELY as their own reconnect-delay hint — entirely independent of go-codex's ownConsume(a browser tab using rawEventSourceagainst a go-codex SSE server benefits from this even without ever touchingadapters/nethttp's client machinery at all). These two halves solve DIFFERENT problems for DIFFERENT consumers and are not one shared mechanism, even though both are colloquially "retry policy."
Open questions (not yet explored)¶
- Event ID source: a monotonic per-
BroadcastHubcounter (simple, but meaningless across server restarts/multiple server instances behind a load balancer) vs. a caller-supplied ID extracted from the domainEventvalue itself (meaningful across restarts if the ID is content-derived, but requires a new declarative mechanism — something likeNewSSEEventIDParam, mirroring the existing merge-field param constructors). Not decided. - Replay semantics when the requested
Last-Event-IDhas aged out of the buffer: silently resume from the OLDEST buffered event (best- effort, may skip some), return an error/close the connection, or fall back to a full resync signal the client must handle? Not decided — each has different implications for at-least-once vs. best-effort delivery guarantees. - Buffer size / eviction policy: fixed count per
BroadcastHub? Time- window based? Configurable per route? Not decided. - Does
RetryPolicy's shape actually need to matchwebhook-adapter.md's identical-looking idea, or do the "infinite reconnect, no real give-up case" (SSE) vs. "finite delivery attempts, real give-up case" (webhook) semantics diverge enough that sharing one type would be a forced, leaky abstraction? Not decided — lean toward investigating whether a genuinely shared type is possible BEFORE assuming it is, given the semantic difference. - Does the server-side
retry:field need to be caller-configurable, or does a single fixed reasonable default (e.g. a few seconds) suffice for Phase 1 of THIS doc, once it has one? Not decided. - Is
id:assignment/buffering scoped tonethttp.SSEAdapterspecifically, or tostream.BroadcastHubitself (making it available to any consumer ofBroadcastHub, not just SSE)?BroadcastHubis a genericstreampackage primitive, not SSE-specific — buffering there could benefit other broadcast consumers too, but might also be scope creep beyond what THIS feature needs. Not decided.
Out of scope (for this doc too, at least for now)¶
- Persistent/durable cross-restart event buffer. Even the modest
in-memory
Last-Event-IDreplay buffer sketched above loses all history on a server restart. A durable buffer (backed byports.Cache/adapters/redis, or a dedicated store) is a materially bigger feature with its own trade-offs (retention policy, storage cost, multi-instance consistency) — a plausible FUTURE follow-on to whatever this doc eventually designs, not part of it. - SSE's
event:field (named/multiplexed event types). A single SSE connection can carry several distinct named event "types" via theevent: <name>line, letting one endpoint multiplex what go-codex today models as several separateSSERoutes (oneEventtype each). This is a related but SEPARATE capability fromLast-Event-IDresume — noted here because it touches the same "parse/write multi-line SSE event blocks" machineryLast-Event-IDresume would also need, but it is a distinct feature with its own design questions (does oneSSERoutebecome able to declare severalEventtypes? how does the client dispatch by event name?) that this doc does not attempt to answer.
Files to create¶
None yet — this doc is findings-only. Once resumed, this section should follow the SAME template SSE Client Consumption used (API surface, structured errors, observer integration, unit test plan, files to create) once the open questions above are resolved.