Skip to content

Gateway: retry pre-stream ECONNRESET, synthesize SSE error on mid-stream reset - #1913

Merged
jwbron merged 9 commits into
mainfrom
egg/issue-1907-v2
Apr 23, 2026
Merged

Gateway: retry pre-stream ECONNRESET, synthesize SSE error on mid-stream reset#1913
jwbron merged 9 commits into
mainfrom
egg/issue-1907-v2

Conversation

@james-in-a-box

Copy link
Copy Markdown
Contributor

Fixes #1907. Makes the gateway's /v1/messages proxy resilient to upstream Anthropic TCP resets.

  • (A) Pre-stream retry: if client.send() or the first iter_bytes() prime raises httpx.ReadError/RemoteProtocolError before any downstream byte has flowed, transparently re-issue the upstream request once. Downstream SDK never sees the error.
  • (B) Mid-stream synthetic error: if a reset arrives after bytes have already streamed, catch it inside generate(), yield a well-formed SSE event: error frame, and close the stream cleanly so the agent's SDK fails gracefully instead of dying on a truncated socket.

Distinct from #1883 (gateway pod restart); this covers the gateway-healthy/upstream-unhealthy case where the fix belongs inside the gateway.

Test Plan

  • Automated: new tests in tests/gateway/test_anthropic_proxy.py::TestStreamingResponsesend() reset → retry success, first-chunk reset → retry success, mid-stream reset → synthetic error frame. Existing streaming tests continue to pass.
  • Manual: run pytest tests/gateway/test_anthropic_proxy.py -v and confirm all green.

Manual Steps

Pre-merge: none beyond CI.
Post-merge: observe gateway logs for logger.warning("upstream reset", ...) entries over the next 24h to confirm the code path is exercising under real traffic and not spuriously triggering on healthy streams.

Pipeline Context

Pipeline: issue-1907-v2
Issue: #1907

Per-phase BRC transcripts: implement.

Authored-by: egg

egg-orchestrator and others added 8 commits April 23, 2026 05:13
Describe the pre-stream retry and mid-stream synthetic SSE error-event
behavior added to proxy_anthropic_messages() for issue #1907. Explains
why mid-stream retry is unsafe (no Anthropic resume tokens) and how the
fix differs from #1883 (gateway pod restart) and #1873 (turn-1 retry).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Anthropic's edge resets long-running SSE connections routinely (load-balancer
rebalance, idle timeout, middlebox). When that happens mid-stream, the gateway
was propagating httpcore.ReadError as a bare socket close, which the downstream
Claude SDK surfaced as a fatal "socket connection was closed unexpectedly" —
killing the agent and losing all in-flight work (#1901 architect lost 282s /
32 turns / $1.33 of context-building this way).

Two complementary fixes inside proxy_anthropic_messages():

- (A) Pre-stream retry. Pre-fetch the first chunk before returning the Flask
  Response. If client.send() or that first iter_bytes() call raises ReadError
  or RemoteProtocolError before any byte has flowed downstream, close the
  failed upstream and reissue the request once. Transparent to the SDK.
  Covers connection-pool staleness and very-early resets.

- (B) Mid-stream synthetic error frame. If the reset arrives after a chunk
  has already been yielded downstream, emit a well-formed Anthropic-style
  'event: error' SSE frame and close the stream cleanly. The SDK treats that
  as a clean API error instead of a truncated socket, and the _SSEAccumulator
  records it in the captured transcript so operators can still see the
  failed turn. Full resumption is not attempted — Anthropic's API has no
  resume tokens.

Distinct from #1883 (gateway pod restart); this covers the gateway-healthy /
upstream-unhealthy case.

Test additions for this change live in
.egg-state/agent-outputs/coder-test-additions-issue-1907.patch and are handed
off to the tester role (tests/ is outside coder's file boundary).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three new tests for TestStreamingResponse in tests/gateway/test_anthropic_proxy.py,
ready for the tester role to apply (tests/ is outside coder's file boundary).

Apply with: git apply .egg-state/agent-outputs/coder-test-additions-issue-1907.patch

Covers:
- (a) client.send() raises ReadError once then retry succeeds
- (b) first iter_bytes() raises ReadError then retry re-primes and succeeds
- (c) mid-stream RemoteProtocolError after one chunk -> synthetic SSE error
  frame appended, stream closes cleanly, upstream.close() still runs

All three verified locally against commit dc5058a: `pytest
tests/gateway/test_anthropic_proxy.py -v` -> 49 passed. Tester is free to
adjust phrasing / add cases; intent is to lock in the contract acceptance
criteria for task-1-3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extend tests/gateway/test_anthropic_proxy.py::TestStreamingResponse with
coverage for the pre-stream retry and mid-stream synthetic error-frame
paths added to proxy_anthropic_messages() in commit dc5058a.

New tests (task-1-3):

- test_streaming_send_reset_retries_once
  client.send() raises httpx.ReadError once, retry succeeds, downstream
  sees a clean 200 SSE response with no synthetic error frame. Verifies
  send() called exactly twice.

- test_streaming_first_chunk_reset_retries_once
  First iter_bytes() pull raises ReadError, gateway re-primes with a
  fresh upstream, stream completes normally. Verifies the failed
  upstream is closed before the retry so the httpx connection pool
  doesn't leak a half-open connection.

- test_streaming_midstream_reset_yields_synthetic_error_frame
  iter_bytes() raises httpx.RemoteProtocolError after one chunk has
  already been yielded. Verifies the downstream body is: original
  chunk + well-formed SSE `event: error` frame with Anthropic-style
  payload, that the body ends with the SSE terminator, that no retry
  is attempted, that upstream.close() still runs, and parses the
  synthetic frame's JSON to catch any malformed output.

Plus one extra defense-in-depth case:

- test_streaming_send_reset_retry_exhausted_returns_502
  Both attempts raise — bounded 1x retry, caller gets 502 and the
  gateway does not loop or leak.

All tests use a small helper _iter_then_raise() that wraps an iterator
so it raises after N yielded chunks, matching the contract's
"helper to wrap an iterator so it raises after N yielded chunks" clause.

pytest tests/gateway/test_anthropic_proxy.py -> 50 passed
ruff check + ruff format --check clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…EADME

Adds a brief note on the /v1/messages endpoint in gateway/README.md that
surfaces the new upstream stream-reset resilience behavior (#1907) and
points readers to the full design rationale in the credential-injection
architecture doc. Also captures it as design decision #11 in the
gateway README so it is discoverable alongside the other enforcement
mechanisms.

The detailed design (pre-stream retry vs. mid-stream synthetic SSE error
frame, bounds, why no full resumption, scope relative to #1883/#1873)
continues to live in docs/architecture/credential-injection.md; this
commit only adds the discoverability breadcrumb from the gateway README.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No agent-mode design concerns.

This PR is pure gateway infrastructure — TCP reset resilience for the /v1/messages proxy. The gateway uses httpx in its designated role as the credential-injection proxy layer (not making its own LLM calls), no prompts are constructed or agent workflows altered, and the synthetic SSE error frame targets the downstream SDK (machine consumer) in Anthropic's documented event shape. All changes are well within the gateway's architectural role.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-scoped change. The two-pronged resilience strategy (pre-stream retry + mid-stream synthetic error) is the right design — it avoids the impossible problem of resuming a stateless stream while still giving the SDK a clean failure signal. Tests cover the four key scenarios thoroughly. A few non-blocking observations below.

Non-blocking suggestions

1. _send_and_prime() — resource leak on unexpected exception types

gateway/gateway.py ~L5278-5292

The except clause in _send_and_prime() only catches (ReadError, RemoteProtocolError). If next(iterator) raised any other exception (e.g. httpx.StreamClosed, a hypothetical RuntimeError), upstream_resp would not be closed.

In practice this is extremely unlikely for iter_bytes() iteration, but a broader except BaseException (close-and-reraise pattern) would be more defensive:

upstream_resp = client.send(http_req, stream=True)
try:
    iterator = upstream_resp.iter_bytes()
    try:
        first = next(iterator)
    except StopIteration:
        first = None
    return upstream_resp, iterator, first
except BaseException:
    try:
        upstream_resp.close()
    except Exception:
        pass
    raise

This ensures any exception from iter_bytes() or the first next() cleans up the upstream connection.

2. Documentation: "gated on bytes_seen == 0" is misleading

Both docs/architecture/credential-injection.md and gateway/README.md describe the pre-stream retry as "gated on bytes_seen == 0". The code doesn't actually check bytes_seen — the invariant is structural: _send_and_prime() raises before any byte reaches generate(), so the retry loop can only fire before downstream streaming begins. The bytes_seen variable is always 0 at that point because _consume_chunk only runs inside generate().

Consider rewording to something like "gated on the first chunk not yet having been yielded downstream" to match the actual mechanism. The current phrasing could mislead a reader into looking for an explicit if bytes_seen == 0 guard that doesn't exist.

3. Vestigial nonlocal bytes_seen in generate()

gateway/gateway.py L5348

After extracting accumulator bookkeeping into _consume_chunk(), generate() no longer writes to bytes_seen — it only reads it (in the logger.warning kwargs). The nonlocal bytes_seen declaration is unnecessary (reading from an enclosing scope doesn't require nonlocal in Python). Harmless but worth cleaning up to avoid confusion about which function owns the mutation.

4. Test: _iter_then_raise as @staticmethod

The _iter_then_raise helper on TestStreamingResponse is a generator function used as a @staticmethod. This works, but since it has no coupling to the test class, it could also live as a module-level helper. Minor style preference — no action needed.

Verified correct

  • _SSEAccumulator handles the synthetic error frame correctly. The event: error line is ignored by _process_line (doesn't start with "data: "), and the data: {"type":"error",...} line is parsed as an error event, added to _error_blocks, and sets _stop_reason = "error". Transcript capture works end-to-end.
  • No information leakage in the synthetic error payload. The error message is a fixed string ("upstream connection reset"), not the raw exception details. Exception details are logged server-side only.
  • request_body is reusable across retries. It's raw bytes from request.get_data(), not a consumed file-like object.
  • The outer except handlers (ConnectError, TimeoutException, generic Exception) correctly catch retry-exhausted failures because the re-raise at attempt == 1 propagates to the enclosing try.
  • No retry for non-transport errors. Application-level errors (4xx/5xx with body) return from _send_and_prime() normally — the retry only fires on the two transport-level exception types.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box james-in-a-box Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contract Verification — PR #1913

Pipeline: issue-1907-v2 | Issue: #1907 | Phase: Implement (phase-1)

Task-by-Task Verification

[task-1-1] Pre-stream retry — VERIFIED

Contract: Add bounded (1x) retry around client.send() and first-chunk prime on httpx.ReadError/RemoteProtocolError before any downstream byte has been yielded. Close the failed upstream, rebuild the request, retry once. On second failure, fall through to the existing error-return path.

Evidence:

  • gateway/gateway.py defines _send_and_prime() (lines 5261–5292) encapsulating build_requestsend(stream=True)iter_bytes()next(iterator). Catches (httpx.ReadError, httpx.RemoteProtocolError), closes failed upstream, re-raises for retry.
  • Outer for attempt in range(2) loop (lines 5297–5312): attempt 0 logs and continues; attempt 1 re-raises to existing except Exception handler → 502.
  • Request is fully rebuilt each attempt via client.build_request — no stale connection-pool reuse.
  • StopIteration on empty upstream correctly sets first_chunk = None.
  • Failed upstream is closed before retry to prevent connection-pool leak.

Acceptance criteria: "When client.send() raises ReadError once, the retry succeeds and downstream sees a clean 200 SSE response. When the first iter_bytes() call raises ReadError, the gateway re-primes and produces a normal stream. Both verified by new unit tests."MET. Tests test_streaming_send_reset_retries_once and test_streaming_first_chunk_reset_retries_once both pass.


[task-1-2] Mid-stream synthetic SSE error — VERIFIED

Contract: In generate(), wrap iter_bytes() loop with try/except for httpx.ReadError/RemoteProtocolError. Yield a well-formed synthetic SSE event: error frame with Anthropic-style payload, feed through accumulator, log warning with container_id and bytes_seen, return cleanly. Preserve finally: upstream.close() and _capture_streaming_response.

Evidence:

  • generate() (lines 5349–5401) wraps yield first_chunk + for chunk in primed_iterator in inner try/except (httpx.ReadError, httpx.RemoteProtocolError).
  • On catch: logs logger.warning with container_id, bytes_seen, error — all required fields present.
  • Builds synthetic payload {"type": "error", "error": {"type": "api_error", "message": "upstream connection reset"}} — correct Anthropic error shape.
  • Encodes as b"event: error\ndata: <json>\n\n" — well-formed SSE frame.
  • Feeds through _consume_chunk() (extracted accumulator helper) before yielding — transcript capture records the failure.
  • finally block (lines 5386–5401) preserved unchanged — upstream.close() and _capture_streaming_response() still run.

Acceptance criteria: "When iter_bytes() raises after one chunk has been yielded, the downstream body contains the original chunk followed by a well-formed event: error SSE frame, the stream closes without raising, and _capture_streaming_response still runs."MET. Test test_streaming_midstream_reset_yields_synthetic_error_frame validates all of these assertions including JSON structure, ordering, and SSE terminator.


[task-1-3] Three new tests — VERIFIED

Contract: Extend TestStreamingResponse with tests covering: (a) client.send() raises ReadError once then succeeds on retry; (b) iter_bytes() raises on first iteration then succeeds on retry; (c) iter_bytes() raises RemoteProtocolError after one chunk — downstream body ends with synthetic event: error frame. Use a small helper to wrap an iterator so it raises after N yielded chunks.

Evidence:

  • _iter_then_raise(chunks, exc) static helper (lines 570–581): yields chunks then raises — matches the "small helper" requirement.
  • Test (a) test_streaming_send_reset_retries_once (line 583): send raises ReadError → retry succeeds → asserts send.call_count == 2, clean 200, no event: error.
  • Test (b) test_streaming_first_chunk_reset_retries_once (line 632): iter_bytes raises on first pull → retry succeeds → asserts send.call_count == 2, bad_response.close called, clean stream.
  • Test (c) test_streaming_midstream_reset_yields_synthetic_error_frame (line 693): iter_bytes raises after one chunk → asserts send.call_count == 1 (no retry), body contains original chunk then event: error, JSON payload parsed and validated, frame terminator correct.
  • Bonus test test_streaming_send_reset_retry_exhausted_returns_502 (line 782): both attempts raise → 502.
  • Existing tests test_streaming_request_detected and test_streaming_content_type_forwarded preserved and passing.

Acceptance criteria: "All three new tests pass. Existing tests in TestStreamingResponse continue to pass."MET. All 6 tests in TestStreamingResponse pass (2 existing + 4 new).

Test Results

tests/gateway/test_anthropic_proxy.py::TestStreamingResponse::test_streaming_request_detected PASSED
tests/gateway/test_anthropic_proxy.py::TestStreamingResponse::test_streaming_content_type_forwarded PASSED
tests/gateway/test_anthropic_proxy.py::TestStreamingResponse::test_streaming_send_reset_retries_once PASSED
tests/gateway/test_anthropic_proxy.py::TestStreamingResponse::test_streaming_first_chunk_reset_retries_once PASSED
tests/gateway/test_anthropic_proxy.py::TestStreamingResponse::test_streaming_midstream_reset_yields_synthetic_error_frame PASSED
tests/gateway/test_anthropic_proxy.py::TestStreamingResponse::test_streaming_send_reset_retry_exhausted_returns_502 PASSED
6 passed in 1.63s

Phase Consistency

  • Phase-1 status: complete — all 3 tasks marked complete ✓
  • No orphaned code outside contract scope — docs/README updates are supplementary documentation aligned with the feature
  • No contract violations found

Supplementary Changes (not in contract, no issues)

  • docs/architecture/credential-injection.md: New "Upstream Stream Resilience" subsection with reset-timing table, design rationale, and scope separation from #1883/#1873.
  • gateway/README.md: Updated /v1/messages endpoint description + new design decision item #11.
  • _consume_chunk() helper extracted from inline accumulator logic in generate() — cleaner, reusable for synthetic frame feeding. Functionally equivalent to the original inline code.

Verdict

All acceptance criteria verified. All tasks complete. No contract violations. Approving.

Note: egg-contract verify-criterion could not be run because the orchestrator is unreachable. Criteria verification is documented above based on code inspection and test execution.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

@james-in-a-box james-in-a-box Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Addressed suggestions 1–3 from the code review in a4a1c06:

  1. _send_and_prime() resource leak: Broadened the except to BaseException with close-and-reraise, so any unexpected exception from iter_bytes() / next() cleans up the upstream connection.

  2. "gated on bytes_seen == 0" wording: Reworded in both docs/architecture/credential-injection.md and gateway/README.md to "gated on the first chunk not yet having been yielded downstream" — matches the actual structural enforcement via _send_and_prime().

  3. Vestigial nonlocal bytes_seen: Removed from generate() since it only reads bytes_seen (which doesn't require nonlocal).

Suggestion 4 (_iter_then_raise as @staticmethod) — no change per reviewer's note that no action is needed.

— Authored by egg

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No agent-mode design concerns (re-review).

The delta since 0166bec is a single commit (a4a1c06) addressing code review feedback — broadened exception cleanup in _send_and_prime(), doc wording fix, and removed a vestigial nonlocal. All mechanical; no change to agent-mode assessment.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the delta from 0166bec to a4a1c06. All three feedback items from the prior review are properly addressed.

Verification of addressed feedback

1. _send_and_prime() resource leak (suggestion #1) — Fixed correctly

The except was broadened from (httpx.ReadError, httpx.RemoteProtocolError) to BaseException with close-and-reraise. This ensures upstream_resp.close() runs on any exception from iter_bytes() / next(), not just the two expected transport errors. The outer retry loop still narrows to just those two exception types, so only transport errors trigger a retry — other exceptions propagate (after cleanup) to the enclosing error handlers. Correct.

2. "gated on bytes_seen == 0" wording (suggestion #2) — Fixed correctly

Both docs/architecture/credential-injection.md and gateway/README.md now read "gated on the first chunk not yet having been yielded downstream" with a parenthetical noting the structural enforcement via _send_and_prime(). Matches the actual mechanism.

3. Vestigial nonlocal bytes_seen (suggestion #3) — Fixed correctly

Removed from generate(). _consume_chunk() retains the nonlocal bytes_seen, capture_truncated declaration and owns the mutation; generate() only reads bytes_seen in the logger.warning kwargs, which doesn't require nonlocal.

No new issues found

The delta introduces no new code paths, no behavioral changes, and no regressions. The three changes are exactly scoped to the feedback.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box james-in-a-box Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contract Re-verification — PR #1913 (incremental)

Pipeline: issue-1907-v2 | Issue: #1907 | Phase: Implement (phase-1)

Delta Since Last Review (0166beca4a1c06)

Single commit addressing three code review suggestions. No new features, no behavioral changes.

Verification of Delta Changes

1. _send_and_prime() resource leak fix — Correct

except (httpx.ReadError, httpx.RemoteProtocolError)except BaseException with close-and-reraise. The outer retry loop at lines 5300–5315 still narrows to only (ReadError, RemoteProtocolError), so retry behavior is unchanged — only cleanup scope is broadened. Any unexpected exception from iter_bytes() / next() now properly closes the upstream connection before propagating.

2. Doc wording fix — Correct

Both docs/architecture/credential-injection.md and gateway/README.md updated from "gated on bytes_seen == 0" to "gated on the first chunk not yet having been yielded downstream". Matches the actual structural enforcement via _send_and_prime().

3. Vestigial nonlocal bytes_seen removal — Correct

Removed from generate(). The nonlocal declaration remains in _consume_chunk() where mutation occurs. generate() only reads bytes_seen in logger.warning kwargs, which does not require nonlocal in Python.

Regression Check

All previously verified acceptance criteria still hold:

Task Criterion Status
task-1-1 Pre-stream retry on ReadError/RemoteProtocolError, bounded 1x Still met — retry loop unchanged
task-1-2 Mid-stream synthetic SSE error frame, stream closes cleanly Still metgenerate() logic unchanged except nonlocal removal
task-1-3 All tests pass, existing tests preserved Still met — 50/50 tests pass

Test Results

tests/gateway/test_anthropic_proxy.py — 50 passed in 1.61s

All 6 TestStreamingResponse tests pass (2 existing + 4 new).

Verdict

No regressions. Delta correctly addresses all prior review feedback. All contract tasks remain verified.

egg-contract verify-criterion could not be run — orchestrator unreachable. Contract has no top-level acceptance_criteria entries; per-task criteria verified via code inspection and test execution above.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

Copy link
Copy Markdown
Contributor Author
egg is addressing review feedback...

@james-in-a-box

Copy link
Copy Markdown
Contributor Author

egg feedback addressed. View run logs

10 previous review(s) hidden.

@jwbron
jwbron merged commit b0c2149 into main Apr 23, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gateway doesn't retry upstream ECONNRESET mid-stream — propagates as fatal agent API error

1 participant