Zylos LogoZylos
2026-09-08

Commitment Boundaries for Safe Retries in Streaming Agents

streamingretriesreliabilityidempotencyagentshttp

Executive Summary

A streaming request can fail after its work has already happened. A client that received no text may still have initiated model generation, incurred work, or dispatched a tool operation. Conversely, receiving a complete model response does not prove that every external tool operation completed. Safe recovery therefore depends on evidence about each operation, not just the HTTP status or the number of tokens displayed.

Three mechanisms need separate names: reconnecting restores a connection; resuming reads an existing operation through a documented cursor; replaying submits the work again. This research compares their contracts, checks official Python SDK implementations, and proposes a recovery decision table for agent developers. A local fault-injection experiment demonstrates why “nothing received” is not sufficient evidence for replay. The recommendations below are engineering synthesis, not additional provider guarantees.

1. Identify which operation is being repeated

An agent turn can contain a model request, a sequence of output events, several tool invocations, and delivery of the final answer. These are different operations. Repeating the model request might produce a different tool call; repeating that tool call might create a duplicate resource; redelivering an already-recorded event might only repeat text on a screen. A recovery policy must identify the operation it governs.

MechanismWhat repeatsContract needed
ReconnectEstablishing a transport connectionConnection/authentication rules; no implied preservation of work
ResumeReading an existing operation after a positionStable operation identity, retained events, documented cursor semantics
ReplaySubmitting an operation againEvidence it was never applied, or an applicable idempotency contract
ContinueA new generation informed by reconciled historyComplete enough context, explicit treatment of partial output and tool outcomes

HTTP supplies a useful baseline. GET, HEAD, PUT and DELETE have idempotent semantics, while POST does not acquire them merely because the client wants retries. RFC 9110 permits automatic retries of a non-idempotent method when the client knows the particular operation is idempotent or can establish that the original was not applied. The same intended effect does not require an identical response body. RFC 9110 §9.2.2

An application can deliberately accept repeated model generation, including additional cost and different text. That is a product choice. It does not automatically authorize duplicate external writes performed by an agent using the model.

2. SSE reconnect is not application recovery

The browser EventSource API specifies reconnection behavior for eligible network failures. A final non-200 response or an incompatible content type instead fails the connection; these are not universal retry triggers. When applicable, the client sends its remembered Last-Event-ID. The server must implement meaningful handling of that position for it to help recover missed events. The protocol alone does not supply a durable application log, retention policy, or exactly-once effect execution. WHATWG Server-Sent Events

Also distinguish an SSE wire format from the browser API. A Python HTTP client or a JavaScript fetch consumer parsing data: lines does not inherit EventSource's reconnection algorithm. Its actual recovery behavior comes from the client implementation and the endpoint contract.

OpenAI documents a more specific mechanism: start a Responses operation with background: true and stream: true, capture the response ID and event sequence, then retrieve that response with streaming and starting_after. This reads the existing response rather than creating another one. The documented retention and data-handling constraints of background mode still apply. OpenAI background mode

The official Python example demonstrates responses.retrieve(response_id=..., stream=True, starting_after=10) following an interrupted read. It establishes a usable API primitive, not an automatic crash-recovery guarantee for every SDK application. OpenAI background streaming example

For a durable consumer, the recovery cursor should represent events safely recorded or applied, not simply the largest sequence observed on the socket. Advancing it before processing creates a loss window. Processing before advancing can create duplicates after a crash, so the consumer also needs idempotent application or a transaction that couples application and cursor advancement. This is a client design recommendation.

3. SDK retries do not establish server idempotency

A source audit must follow a value all the way to the request sent over the wire. Finding an internal function named _idempotency_key() is insufficient.

At the inspected OpenAI Python commit, the base client can generate an internal key once and reuse it inside a call's retry loop. But _idempotency_header is initialized to None, and header emission is conditional on a configured header name. The ordinary client at the same commit does not override that setting. This code therefore does not substantiate a claim that the standard client sends an idempotency header or that the server deduplicates generation. OpenAI base client, OpenAI client

The inspected Anthropic client has the same distinction: internal key plumbing exists, but the default header name is unset and emission is gated. This is why implementation evidence needs both the base class and the configured concrete client. Anthropic base client, Anthropic client

Both clients document two automatic retries by default—up to three attempts—for connection failures and selected HTTP failures including 408, 409, 429 and server errors. Configuration and explicit server retry hints can alter behavior. That transport policy is separate from recovery after an application has started consuming streamed events. OpenAI retry documentation, Anthropic retry documentation

A real endpoint idempotency contract specifies scope, parameter matching, retention and concurrent-request behavior. Stripe, for example, documents retaining the first executed result under a key, returning it on repeat requests, rejecting mismatched parameters, and treating reuse after pruning as a new request. Validation failures and concurrent conflicts have their own rules. These guarantees apply to Stripe's documented endpoints; copying the header name into another API does not copy the guarantees. Stripe idempotent requests

For an agent harness, account for nested retries as well: an application-level retry can multiply attempts already made by the SDK. One visible “retry” button is not necessarily one network attempt. Log both logical-operation identity and individual attempt identity, with separate budgets for generation and external effects.

4. Authentication recovery is a separate decision

An initial HTTP 401 and an error event inside an HTTP 200 stream are different observations. Once the response has begun, the server cannot replace its HTTP status with 401; it can emit a protocol error or terminate the stream. Anthropic explicitly documents errors delivered inside SSE streams after the initial successful HTTP response. Claude API errors

For OAuth bearer tokens, invalid_token can mean expiration, revocation, malformed input or another invalidity. RFC 6750 permits obtaining a new access token and retrying. It does not make local expiry the prerequisite, nor guarantee that a refresh grant remains valid. An access token becoming invalid does not, by itself, prove its refresh credential is unusable. RFC 6750 §3.1

A practical policy for an integration that actually supports refresh is one bounded forced refresh after the appropriate authentication failure, even if the cached expiry is still in the future. Permanent refresh failure should surface a reconnection requirement; transient refresh failure should retain its distinct classification. Whether to replay the original operation remains a second question: an explicit authentication rejection at the gate is different from an ambiguous disconnect after work may have begun. API-key integrations must follow their own credential contract rather than inventing an OAuth refresh flow.

5. Record observations without overstating what they prove

The following table is a proposed client model. Its rows describe available evidence, not universal server execution stages.

ObservationWhat remains uncertainRecovery decision
Transport proves this request was never transmittedNo remote execution from this attemptSend, subject to the normal budget
Request may have been sent; no response arrivedAcceptance, generation, hosted-tool effectsUse supported operation lookup or idempotency; otherwise retain an ambiguous outcome
HTTP 200 and stream headers receivedWhether output or effects already exist beyond the clientCapture any operation identity; prefer supported retrieval/resumption
Partial text or tool-call data receivedRemaining output; hosted effects; downstream consumers' progressPreserve partial data; resume, or plan an explicit continuation after reconciliation
A client tool invocation was dispatchedWhether that tool committed its effectReconcile or retry that tool under its own operation contract
A successful model terminal event was receivedWhether separate tool work and local persistence are completeRecord model completion; settle other operations independently
EOF or error without successful terminal evidenceWhether the generation finished elsewhereMark interrupted or unknown; do not turn iterator exhaustion into success

Three consequences are easy to miss. First, dispatch is not proof of commitment. Second, partial text is not proof that no hosted tool has run. Third, a model's terminal event does not certify all downstream work.

Anthropic's documented interrupted-text recovery constructs a new continuation request. Its guidance differs by model generation and excludes partial recovery of tool-use and thinking blocks. Such continuation is useful, but it is not cursor-based resumption of the same generation or a guarantee against repeating already-executed tools. Claude streaming recovery

Only execute validated, complete tool invocations under the applicable protocol. Keep a durable record linking the tool's logical operation, normalized parameters and outcome. If a regenerated model turn proposes another call ID for the same business action, blindly using the new ID as a fresh idempotency key can still duplicate the effect. Tool-call identity and business-operation identity require an explicit mapping.

6. A recovery decision function, not a generic retry loop

This pseudocode expresses policy, not a provider API. Each predicate must be backed by observed state or an explicit endpoint contract.

recover(record, failure):
    if record.model_success_durably_recorded:
        return settle_remaining_tools_and_delivery
    if record.tool_effect_unknown:
        return reconcile_tool_operation
    if failure.proves_request_never_transmitted:
        return bounded_send
    if failure.is_auth_rejection and auth_contract.supports_refresh:
        return bounded_refresh_then_reclassify_original_operation
    if record.operation_id and contract.supports_resume(record):
        return resume_after_durably_applied_cursor
    if contract.guarantees_replay(record.key, record.parameters):
        return bounded_replay_with_same_key
    return preserve_partial_result_and_report_ambiguous_outcome

Successful completion requires the provider's successful terminal evidence and local persistence of the necessary result. EOF, a failed terminal event, an incomplete outcome, and user cancellation each need their own handling. Cancellation should not silently become permission to launch a replacement operation. OpenAI Responses streaming event reference

Persisting a client operation ID before sending helps correlate attempts, but cannot alone solve the interval between server acceptance and learning the server's response ID. Save that ID as soon as it is actually received. If the process dies first, recovery still requires server-supported lookup or idempotency; the client must not manufacture certainty from its own journal.

7. Local fault injection: no response, two effects

A synthetic Python HTTP server was exercised locally during this research. Its POST handler incremented an in-memory effect counter, then deliberately closed the connection before sending any HTTP response. A second request without a key incremented the counter again. With an operation key, the server retained and returned the first result. A separate GET read existing operation events after a fixed simulated cursor (the slice after the first event).

ExperimentObserved effect count
Unkeyed POST, disconnect before response, repeat POST2 effects
Same failure with a stable operation key and explicit server deduplication1 additional effect across both attempts
Retrieve the keyed operation's remaining events0 additional effects

The unkeyed case is a positive control: the test actually detects duplication. The keyed comparison changes server semantics, not merely client headers. No model provider or production service was contacted. This experiment establishes that the failure mechanism is possible; it does not measure a vendor's billing or hosted-tool behavior.

The essential server logic is small:

# Synthetic single-threaded model; intentionally not durable production code.
def execute(key, drop_response):
    global effect_count
    if key is None or key not in results:
        effect_count += 1
        result = {"effect": effect_count, "events": ["accepted", "done"]}
        if key is not None:
            results[key] = result
    else:
        result = results[key]
    if drop_response:
        return CLOSE_CONNECTION_WITHOUT_RESPONSE
    return result

In the actual probe, the sentinel above was implemented by returning from an HTTP handler with the connection set to close; the client observed RemoteDisconnected. Real deduplication additionally requires durable storage, atomic arbitration between concurrent attempts, parameter checks and a retention policy. An in-memory dictionary proves none of those properties.

8. Failure-injection acceptance matrix

These are proposed integration tests, beyond the local experiment already run.

FaultRequired assertion
Proven DNS/connect failure before transmissionA new send is allowed even when the operation includes tools
Connection closes after server acceptance but before headersClient preserves uncertainty; no unsupported replay
Access token rejected while cached expiry is in the futureSupported bounded refresh remains eligible; no expiry-only veto
SSE error after HTTP 200Error payload is classified; initial HTTP success does not mask failure
Stream ends after a text delta without a terminal eventPartial text is retained; generation is not marked successful
Crash after recording an event but before advancing the cursorRedelivery does not repeat its application or tool effect
Tool commits, then its response is lostReconciliation/same-key retry yields one effect, or uncertainty stays explicit
Crash before receiving the server operation IDClient ID alone does not unlock a fictitious resume path
Same operation key reused with different parametersLocal/server contract rejects the mismatch
User cancels while a recovery timer is pendingNo automatic replacement generation starts

Limits and applicability

The SDK observations are pinned to the linked commits, not promised for every future release or every language client. Hosted-tool execution and charging after an ambiguous disconnect require provider-specific evidence; the local experiment deliberately makes no such claims. Response retention can expire, so a documented resume API is not unlimited recovery storage. Finally, resuming model output cannot make a separate tool API transactional.

For Zylos-style agent runtimes, a useful implementation boundary is one recovery owner per logical operation: the transport layer classifies connection failures, the generation adapter understands terminal events and provider cursors, and the tool executor owns external effects. Share evidence between those layers. Do not let each independently replay the whole turn.