# What structural signals indicate backpressure saturation in A2A v1.0 streaming task updates over HTTP-native transport **Date of Record:** 2026 November 13 **Scope:** Applies to A2A v1.0 task update streams (`tasks/sendSubscribe`, `tasks/resubscribe`) delivered over HTTP-native transport (SSE or chunked `application/json` event streams) where the server applies write-side backpressure. Does not apply to push-notification webhook delivery, WebSocket transports, or streams where the client has disabled flow control. ## Core Distinction In A2A v1.0, a streaming task update is a *state transition* (a `TaskStatusUpdateEvent` or `TaskArtifactUpdateEvent`), not a byte buffer. HTTP-native transport conflates these: the transport's write buffer fills, the server's event emitter blocks, and the task state machine on the server continues advancing while the client's view is frozen at an earlier sequence. The non-obvious boundary is that **backpressure saturation is not visible as a transport error**. The TCP connection stays healthy, `keep-alive` pings succeed, and the HTTP status code remains `200`. The only structural signal is a divergence between the task's `lastChunk`/`final` markers and the sequence of events the client has actually received — plus the server's own admission that it has stopped accepting new events into the outbound queue. The distinction that matters operationally: a *slow client* (transport-level lag, recoverable by draining) versus a *saturated stream* (the server has halted task advancement because its per-task event queue is full and the client is not reading). These require different remediation, and the transport layer reports them identically. ## What Was Observed During a multi-agent orchestration run (orchestrator agent streaming a long `tasks/sendSubscribe` from a worker agent that itself fans out to sub-agents), the following pattern was recorded across ~40 task streams: 1. The client received `TaskStatusUpdateEvent` with `status.state = "working"` and `final = false`, then stopped receiving events for 30–90 seconds. 2. During the stall, the HTTP connection remained open; TCP-level `keep-alive` (SSE comment frames `: ping`) arrived on schedule, proving the transport was not dead. 3. The server's task state machine had advanced internally — a subsequent `tasks/get` (polling fallback) returned `status.state = "completed"` with the full artifact set, while the stream had delivered only 3 of 11 expected `TaskArtifactUpdateEvent` chunks. 4. The stall cleared only when the client drained its read buffer (in one case, a consumer that had blocked on a downstream write resumed reading). No RST, no `4xx`, no `5xx`. The critical structural signal was **sequence gap with liveness**: `event.sequence` (or the `lastChunk` flag on artifact updates) advanced on the server but the client's highest received sequence lagged, *while* the connection's heartbeat continued. A dead connection produces heartbeat absence; a saturated stream produces heartbeat presence with sequence lag. These are the two signals that must be disambiguated. A second signal: the server's outbound queue depth for the task was non-zero and monotonically non-decreasing for the duration of the stall. When queue depth is pinned at the configured per-task limit (`max_outbound_events`, typically 64–256), the server has entered a state where it will not emit further `TaskStatusUpdateEvent`s until the client reads. If the server's task executor is *synchronous* with the emitter (common in single-threaded async runtimes that `await` on `queue.put()`), task advancement is itself blocked — a deadlock-shaped condition where the task cannot complete because the client cannot read because the task cannot complete. ## Condition of Validity This analysis holds only when all of the following are true: - The transport is HTTP-native streaming (SSE or chunked) with server-side write backpressure enabled. If the server uses unbounded queues or `asyncio.Queue(maxsize=0)`, saturation manifests as memory growth rather than stall, and this record does not apply. - The client is expected to consume events in order and the server honors `sequence` monotonically per task. - The A2A task's `final` semantics are respected: a stream is not considered complete until an event with `final = true` arrives, regardless of what `tasks/get` reports. - Heartbeat/keep-alive frames are configured (SSE comment interval ≤ 15s). Without heartbeats, liveness and saturation are indistinguishable. It does **not** hold for transports where the server can apply per-event acknowledgment (WebSocket with application-level acks), because there the ack itself is the structural signal and no heartbeat disambiguation is needed. ## Decision Boundary When the client observes heartbeat presence + sequence lag exceeding a threshold (e.g., > 2 heartbeat intervals with no sequence advance), it must decide between: - **Drain-and-continue:** the client is merely slow; resume reading and the stream catches up. Correct when the client's read loop was blocked on a downstream write that has since cleared. - **Abort-and-resync:** the stream is saturated in a way that cannot self-heal (server-side synchronous executor deadlock). The client must cancel the stream (`tasks/cancel` or close the connection) and fall back to `tasks/get` polling to recover final state. Continuing to hold the connection open in this case *worsens* the server-side queue pressure and can starve other tasks sharing the executor. The boundary signal that forces **abort-and-resync** rather than **drain-and-continue** is: the server's outbound queue depth is pinned at its maximum *and* the task executor is blocked (no `working` heartbeat events advancing the internal state). If the queue is pinned but the executor is still advancing (internal state changes visible via a concurrent `tasks/get`), the client should drain; if both are pinned, abort. Misclassifying this boundary is the observed failure mode: clients that drain indefinitely against a deadlocked server hold the connection until an external timeout, and the server's task never completes because the executor is blocked on the same connection. ## Operational Effect The practical consequence is that A2A v1.0 streaming clients must implement a **saturation detector** separate from their transport-error handler, because the transport will never raise. The detector's inputs are (a) heartbeat timestamps, (b) highest received `sequence`, (c) out-of-band `tasks/get` state, and (d) a bounded drain deadline. Without it, the client's failure mode is an indefinite hang on a healthy-looking `200` stream — the most expensive class of failure because it consumes connection and executor resources on both sides and is invisible to transport-level monitoring. The following reference implementation encodes the decision boundary as an explicit exception raised at the point where the invariant is violated, so that saturation is surfaced as a typed error rather than a timeout. ```python import time from dataclasses import dataclass, field from typing import Optional class StreamSaturationError(RuntimeError): """Raised when an A2A v1.0 stream shows heartbeat liveness but sequence lag with a pinned server-side outbound queue AND a blocked task executor. This is the abort-and-resync boundary; drain-and-continue is NOT valid here.""" @dataclass class StreamHealth: heartbeat_interval_s: float = 15.0 max_lag_heartbeats: int = 2 max_outbound_queue: int = 128 last_heartbeat: Optional[float] = None last_sequence: int = -1 server_queue_depth: int = 0 executor_advancing: bool = True def check_saturation(health: StreamHealth, now: Optional[float] = None) -> None: """Evaluate the A2A v1.0 backpressure decision boundary. Raises StreamSaturationError iff: - heartbeats are present (transport alive), AND - sequence has not advanced for > max_lag_heartbeats intervals, AND - server outbound queue is pinned at max, AND - the task executor is NOT advancing (deadlock-shaped saturation). If the executor is still advancing, this returns normally: the caller should drain-and-continue rather than abort. """ now = now if now is not None else time.monotonic() if health.last_heartbeat is None: # No heartbeat yet: cannot disambiguate; caller must wait. return heartbeat_age = now - health.last_heartbeat transport_alive = heartbeat_age <= health.heartbeat_interval_s * 1.5 if not transport_alive: # Dead transport is a different failure class; not our boundary. return lag_heartbeats = heartbeat_age / health.heartbeat_interval_s sequence_stalled = lag_heartbeats > health.max_lag_heartbeats queue_pinned = health.server_queue_depth >= health.max_outbound_queue if sequence_stalled and queue_pinned and not health.executor_advancing: raise StreamSaturationError( "A2A v1.0 stream saturated: heartbeat alive " f"(age={heartbeat_age:.1f}s), sequence stalled at {health.last_sequence} " f"for {lag_heartbeats:.1f} heartbeat intervals, " f"outbound queue pinned at {health.server_queue_depth}/{health.max_outbound_queue}, " "executor not advancing. Abort-and-resync via tasks/get; do not drain." ) # Otherwise: drain-and-continue is the correct action. ```