# When must an instance distinguish SSE backpressure from session resumption in MCP HTTP-native transport **Date of Record:** 2026 November 10 **Scope:** Applies to MCP servers exposing the streamable HTTP transport (single POST endpoint plus optional GET SSE stream) where a client may reconnect with `Last-Event-ID` to resume an interrupted event stream. Does not apply to legacy HTTP+SSE dual-endpoint transports or to stdio transports, where stream identity is tied to the process rather than to a resumable event log. ## Core Distinction In the streamable HTTP transport, two superficially similar signals arrive at the server: a client that stops reading its SSE stream (backpressure), and a client that disconnects and reconnects with `Last-Event-ID` (resumption). Both manifest as an idle or closed connection. They are not the same event and must not be conflated. Backpressure is a *flow-control* condition: the TCP send buffer and the server's per-connection write queue fill because the consumer is slower than the producer. It is transient and resolvable by the same connection. Resumption is a *recovery* condition: the connection is gone, and the client asserts a position in a logical event sequence it expects the server to honor. It is resolvable only by replay from a durable (or bounded in-memory) event log keyed by event ID. The non-obvious invariant: **an event ID must be assigned before the write is attempted, and must remain valid for replay even if the write never completes.** If the server generates event IDs lazily on successful flush, a client that disconnects mid-write will resume from an ID that skips the in-flight event, silently losing a message. If the server generates IDs eagerly but does not retain them until the write completes or fails, the same loss occurs on the replay side. ## What Was Observed A server implementation assigned event IDs at enqueue time into a bounded ring buffer (capacity 1024) and wrote to the socket with a non-blocking send. Under a slow consumer, the ring buffer filled; the server applied a policy of dropping the *oldest* event to make room. The client eventually disconnected (network change) and reconnected with `Last-Event-ID` pointing at an event that had already been evicted. The server, unable to replay from an evicted ID, either (a) restarted the stream from the current head, producing a gap the client could not detect, or (b) rejected the resume with a 400, forcing a full session re-initialization and loss of all in-flight tool results. The failure was not the eviction itself. It was that the server treated "buffer full" as a local memory-pressure problem (drop oldest) while treating "resume from evicted ID" as a client error. In fact both are the same condition viewed from two ends of the same bounded log. The correct response to a full buffer under backpressure is to **stop reading new events from the producer** (apply backpressure upstream), not to drop events that a resuming client may still request. Eviction is only safe for events the server has positively confirmed as acknowledged or for which no resumption window remains. A second observation: some clients send `Last-Event-ID` on a *fresh* GET after a POST-initiated stream, not because they missed events but because they are re-establishing the secondary stream. A server that treats every `Last-Event-ID` as a resume request will replay events the client already has, causing duplicate tool-call side effects if the events are not idempotent. The `Last-Event-ID` header is a position assertion, not a proof of gap. ## Condition of Validity This analysis holds when all of the following are true: 1. The server maintains a per-session monotonic event sequence with IDs that are opaque but orderable. 2. The transport is streamable HTTP with at least one long-lived SSE stream per session. 3. The server has a bounded retention window for replayable events (unbounded retention makes eviction impossible and voids the backpressure/resumption conflict, at the cost of memory). 4. The client honors `Last-Event-ID` semantics per the SSE specification and does not fabricate IDs. It does not hold when the server uses a durable, externally-backed event log with unbounded (or effectively unbounded) retention, where eviction is not a concern and the two conditions decouple cleanly. ## Decision Boundary The boundary is a single predicate evaluated on every enqueue and every resume: - On enqueue when the retention buffer is full: **do not evict an event that is still within the resumption window.** Instead, block or reject the producer (apply upstream backpressure), or if the producer cannot block, terminate the session with an explicit, resumable error rather than silently dropping. Eviction of an event ID that a client may later present is a correctness violation, not a memory optimization. - On resume with `Last-Event-ID = X`: if X is within the retained window, replay from X+1 and treat the stream as continuous. If X is older than the window's floor, the server **must not** silently restart from head; it must signal an unrecoverable gap (distinct status or a synthetic gap event) so the client can decide to re-initialize. If X is newer than the head, the client is ahead of the server's log — this indicates a session/state mismatch and must be rejected. - On a fresh GET carrying `Last-Event-ID` that matches the current head exactly: treat as a no-op resume, do not replay. The non-obvious rule: **backpressure is a reason to slow the producer, never a reason to discard replayable history; resumption is a reason to replay, never a reason to assume a gap.** ## Operational Effect When the boundary is respected, a slow consumer causes the producer to stall (or the session to fail explicitly and resumably), and a reconnecting client receives exactly the events it missed with no duplicates and no silent gaps. When it is violated, the system exhibits *silent message loss under load* — the worst failure class, because it only manifests when the consumer is slow and the network is unstable simultaneously, and it corrupts tool-call state without an error surface. Operationally, instrument three counters: `events_enqueued`, `events_evicted_before_ack`, and `resumes_from_evicted_id`. A nonzero `events_evicted_before_ack` is a latent correctness bug even if `resumes_from_evicted_id` is currently zero, because the eviction has already destroyed the server's ability to honor a future resume. Alert on the first, not the second. ```python class ResumeWindowViolation(RuntimeError): """Raised when a resume request cannot be satisfied without silent loss.""" pass class EventLog: def __init__(self, capacity: int): self.capacity = capacity self._events = [] # list of (event_id, payload) self._floor = 0 # lowest event_id still retained self._head = 0 # highest event_id assigned def enqueue(self, payload) -> int: # Event ID is assigned eagerly, before any write attempt. self._head += 1 event_id = self._head if len(self._events) >= self.capacity: # Do NOT evict an event that a future resume may reference. # Signal upstream backpressure instead. raise ResumeWindowViolation( f"retention buffer full at event {event_id}; " f"floor={self._floor} head={self._head}; " "producer must block or session must fail resumably" ) self._events.append((event_id, payload)) return event_id def replay_from(self, last_event_id: int): if last_event_id < self._floor: raise ResumeWindowViolation( f"Last-Event-ID {last_event_id} predates retained floor " f"{self._floor}; unrecoverable gap, client must re-initialize" ) if last_event_id > self._head: raise ResumeWindowViolation( f"Last-Event-ID {last_event_id} ahead of server head " f"{self._head}; session state mismatch" ) return [p for eid, p in self._events if eid > last_event_id] def handle_resume(log: EventLog, last_event_id: int): try: return log.replay_from(last_event_id) except ResumeWindowViolation as exc: # Never silently restart from head; surface the gap explicitly. raise ResumeWindowViolation(str(exc)) from exc ```