Skip to content

fix(endpoint-client): retry pre-response connection resets (opt-in)#404

Open
Palanivelg wants to merge 2 commits into
mlcommons:mainfrom
Palanivelg:feat/transport-retry-pre-response-reset
Open

fix(endpoint-client): retry pre-response connection resets (opt-in)#404
Palanivelg wants to merge 2 commits into
mlcommons:mainfrom
Palanivelg:feat/transport-retry-pre-response-reset

Conversation

@Palanivelg

Copy link
Copy Markdown
Contributor

Summary

  • Adds an opt-in transport retry that re-issues a request on a fresh connection when the server closes an idle keep-alive socket before any response byte arrives (the single-slot-server reuse/reset race). It never fires once headers/chunks have started, so it cannot duplicate output.
  • New HTTPClientConfig.transport_max_retriesdefault 0 = prior fail-fast behavior, so every existing use case is unchanged. The edge-agentic accuracy reference config opts in explicitly.
  • Root cause: reused-then-reset sockets on single-slot servers failed requests before any response arrived, silently zeroing the affected accuracy sample. The retry recovers them without disabling connection reuse.

Changes

  • endpoint_client/worker.py: _read_headers_with_retry helper
  • endpoint_client/config.py: transport_max_retries field (default 0)
  • examples/11_Edge_Agentic_Example/online_edge_full_run.yaml: opts in
  • regenerated _full config templates (schema-driven)
  • tests/unit/endpoint_client/test_worker_retry.py: unit tests

Test plan

  • pytest tests/unit/endpoint_client/test_worker_retry.py — all pass
  • pre-commit run on all changed files (ruff, ruff-format, mypy, template regen, license) — all pass
  • Full accuracy pass with the fix enabled: no connection resets; accuracy back on the reference baseline (previously degraded by dropped requests)

Single-slot servers that recycle idle keep-alive sockets between requests
can close a socket at the instant the client reuses it. The write then
races the close and raises a connection reset before any response byte
arrives, which previously failed the request outright -- and on an
accuracy run, silently zeroed the affected sample.

Add Worker._read_headers_with_retry: on a connection failure before any
response byte, discard the dead connection and re-issue the identical
request on a fresh one. It never fires once headers/chunks have started,
so it cannot duplicate output. Gated by a new HTTPClientConfig field
transport_max_retries (default 0 = prior fail-fast behavior, so every
existing use case is unchanged); the edge-agentic accuracy reference
config opts in explicitly.

Adds unit tests covering retry-success, retries-exhausted,
retries-disabled, the no-reset fast path, and the opt-in default.
@Palanivelg Palanivelg requested a review from a team July 8, 2026 23:44
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

MLCommons CLA bot All contributors have signed the MLCommons CLA ✍️ ✅

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces a retry mechanism for HTTP requests that fail due to pre-response connection resets, which commonly occurs when a server closes an idle keep-alive socket just as the client sends a request. This behavior is controlled by a new transport_max_retries configuration parameter. The feedback highlights a potential connection leak in the retry loop of _read_headers_with_retry if writing to a newly acquired connection fails before it is assigned to the request, and suggests assigning the connection immediately after acquisition to ensure it is properly released in the finally block.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +408 to +410
new_conn.protocol.write(req.http_bytes)
req.connection = new_conn

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.

high

If new_conn.protocol.write(req.http_bytes) raises an exception (e.g., due to a closed transport or other unexpected errors), the newly acquired connection new_conn will be leaked. This is because req.connection has not yet been updated to new_conn, so the finally block in _process_response will only release the old, already-released connection.\n\nTo prevent this resource leak, assign req.connection = new_conn immediately after acquiring it, before performing the write operation.

                new_conn = await self._pool.acquire()\n                req.connection = new_conn\n                new_conn.protocol.write(req.http_bytes)

Assign req.connection to the freshly acquired connection before writing
the retried request, so a write failure leaves the request owning a
releasable connection (the caller's finally-block releases it) instead of
leaking it. Adds a regression test for the failing-write path.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.45455% with 1 line in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@85a9375). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/inference_endpoint/endpoint_client/worker.py 95.23% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #404   +/-   ##
=======================================
  Coverage        ?   80.04%           
=======================================
  Files           ?      129           
  Lines           ?    17245           
  Branches        ?        0           
=======================================
  Hits            ?    13804           
  Misses          ?     3441           
  Partials        ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment on lines +109 to +114
# Single-slot edge servers (e.g. llama.cpp -np 1) close idle keep-alive
# sockets between samples; a reused-then-reset socket would otherwise zero
# the affected accuracy sample. Retry the request on a fresh connection when
# the reset happens before any response byte (safe: the server produced no
# output). Off by default globally; enabled here for the gated run.
transport_max_retries: 2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@viraatc can you help review this PR? In general I want to avoid adding more tuning flags to our http client if possible

@nvzhihanj nvzhihanj requested review from arekay-nv and viraatc July 9, 2026 00:19

@arekay-nv arekay-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review Council — Multi-AI Code Review

Reviewed by: Codex (gpt-5.5, xhigh) + Claude + manual trace | Depth: thorough

Small diff, but two design decisions land on the reproducibility / perf-masking concerns raised for this PR. 6 issues posted inline.

🔴 Must Fix (high)

File:Line Category Summary
worker.py:396 observability Successful retry is invisible (no log/metric/counter/flag/event) → server resets under load masked as clean successes instead of surfaced to tune connection settings
worker.py:381 performance Retried-sample latency (dead-socket wait + reacquire + re-write + full 2nd server pass) is folded into the sample and attributed to the server with no marker; inflates p99/max on exactly the single-slot samples this targets
config.py:184 reproducibility transport_max_retries not pinned by check_config_lock and unbounded (ge=0, no le=); recovered resets can silently satisfy no_dropped_turns; reference config sets it to 2

🟡 Should Fix (medium)

File:Line Category Summary
worker.py:397 correctness Retries on a partial-response close, not just zero-byte → possible duplicate server-side generation; "safe and idempotent" overstated (Codex + Claude)
worker.py:447 concurrency release not idempotent for a dead conn (never clears in_use) → double-release fires spurious _notify_waiter; comment inaccurate
test_worker_retry.py:124 testing Tests never drive _process_response, don't model in_use, no multi-retry-then-success, no observability assertion

Note: the earlier agent comment on worker.py (write-before-assign leak) is resolved by commit b9ca904.

conn = req.connection
try:
status_code, _ = await conn.protocol.read_headers()
return status_code

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Council: Claude + manual trace] high — perf-regression masking

A successful retry returns the status here with zero trace — no log, no metric, no counter, no per-sample flag, no event. Only the exhausted path (_handle_error, ~L404) and the disabled path record anything. So when a server resets connections under load — a "SUT can't keep up" signal you'd use to tune max_connections/max_idle_time/warmup_connections/server keep-alive — the retry converts it into a clean success and the signal never reaches the metrics/report.

Suggest: logger.warning(...) on each recovery + an aggregatable counter (e.g. transport_retries_total) + a per-sample retry flag on QueryResult, so the Report can surface how many samples needed re-issue.

logger.error(f"Request {req.query_id} failed: {type(e).__name__}: {e}")
return False

async def _read_headers_with_retry(self, req: InFlightRequest) -> int | None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Council: Claude + manual trace] high — latency pollution attributed to the server

This whole method runs inside the timed _process_response, so a retried sample's elapsed window now folds in: waiting for connection_lost on the dead socket (server FIN/RST timing) + _pool.acquire() of a fresh conn + re-write() + a full second server processing pass — with no marker distinguishing it. On single-slot accuracy runs (the exact scenario this targets) resets are the common case, so this inflates p99/max for precisely the samples the feature exists to handle.

Suggest flagging retried samples in QueryResult so the report can segregate/exclude them instead of blending polluted latency into the server's distribution.

# Opt-in: default 0 preserves the prior fail-fast behavior for every use
# case. Configs that need it (e.g. the edge-agentic accuracy reference)
# enable it explicitly via `client.transport_max_retries`.
transport_max_retries: int = Field(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Council: Claude + manual trace] high — reproducibility / compliance hole on this knob

  1. compliance/checker.py:check_config_lock pins seed + single-stream (num_workers/max_connections/target_concurrency) but never inspects transport_max_retries, even though the resolved config carries it at settings.client.transport_max_retries and the edge-agentic reference (online_edge_full_run.yaml:114) sets it to 2. The config-lock gate therefore passes for any value, it isn't surfaced as a manual attestation, and a retry-recovered reset can let the no_dropped_turns gate pass on a run where a turn was effectively dropped-and-re-issued.
  2. The field is unbounded (ge=0, no le=): against a genuinely-down server every attempt re-acquires/re-writes/re-raises, turning fast-fail into a long connection-churn spin that masks a hard SUT failure and multiplies load.

Suggest: pin transport_max_retries in check_config_lock to the ruleset value (0 for perf, the disclosed value for gated accuracy), add le= (e.g. 5), and record recovered resets so "reproducible + 0 dropped turns" can't be satisfied silently. (Dovetails with the maintainer note about limiting tuning flags.)

try:
status_code, _ = await conn.protocol.read_headers()
return status_code
except ConnectionError as e:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Council: Codex + Claude + manual trace] medium — retries on a partial-response close, not just a true zero-byte close

connection_lost raises ConnectionResetError whenever headers aren't complete (http.py ~L216), so a peer that emitted partial status-line/header bytes and then dropped still lands in this except and re-issues. Client output isn't duplicated (the accumulator only runs after a 200), but the server may have already received and processed the request → duplicate generation work, i.e. extra invisible load that worsens the very overload that caused the reset. The docstring's "no output was produced… safe and idempotent" and the comment "no headers arrived" overstate this — it's client-output-idempotent, not server-side-idempotent.

Suggest tracking whether any response bytes were received and only retrying true zero-byte closes; scope the docstring claim to client output.

self._pool.release(conn)
# Release the current connection (body handlers release early; this
# is idempotent and also covers the swapped connection after a retry)
self._pool.release(req.connection)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Council: Claude + manual trace] medium — "idempotent" is inaccurate for a dead connection

ConnectionPool.release (http.py:586) only early-returns on not conn.in_use, and its dead branch closes the transport without clearing in_use. In the retry-exhausted and retries-disabled paths the last (dead) connection is released once inside _read_headers_with_retry (~L402) and again here, so the second call re-enters the dead branch and fires a spurious _notify_waiter(). Impact is low in single-slot mode (a spurious wakeup that re-waits), but the comment will mislead maintainers.

Suggest either resetting in_use in the dead branch of release, or dropping the idempotency claim and guarding the double-release explicitly.


@pytest.mark.unit
@pytest.mark.asyncio
async def test_retry_exhausted_emits_error_and_returns_none():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Council: Claude] medium — test gaps on this PR's risk areas

Every test calls _read_headers_with_retry directly and never drives _process_response, so the finally: self._pool.release(req.connection) double-release path (worker.py L447) is never exercised. _FakeConn/_FakePool don't model in_use, so they couldn't detect the dead-conn idempotency defect even if driven. There's no multi-retry-then-success case (e.g. ["reset","reset",200] with max_retries=2), and nothing asserts observability of a successful retry (there's nothing to assert — itself the gap).

Suggest an integration-style test that drives _process_response end-to-end with an in_use-tracking fake pool.

@arekay-nv arekay-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we root cause this more accurately. Ideally we would like to tune the other settings and not have silent retries impacting the performance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants