-
Notifications
You must be signed in to change notification settings - Fork 23
fix(endpoint-client): retry pre-response connection resets (opt-in) #404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -170,6 +170,26 @@ class HTTPClientConfig(WithUpdatesMixin, BaseModel): | |
| 4.0, description="Discard connections idle longer than this (seconds)" | ||
| ) | ||
|
|
||
| # Retry a request when the connection fails BEFORE any response byte is | ||
| # received (e.g. "connection closed before headers received" — the server | ||
| # closed an idle keep-alive socket at the instant we wrote the request). | ||
| # Safe because the server produced no output; the retry re-issues on a fresh | ||
| # connection. Never fires once headers/chunks have started (would risk | ||
| # duplicate output). Guards single-stream localhost runs (e.g. llama.cpp with | ||
| # -np 1) where a stale-socket reset otherwise zeroes an accuracy sample. | ||
| # | ||
| # 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`. | ||
| # Bounded above so a genuinely-down server can't turn fail-fast into a long | ||
| # reconnect/re-write spin that masks a hard SUT failure and multiplies load. | ||
| transport_max_retries: int = Field( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Council: Claude + manual trace] high — reproducibility / compliance hole on this knob
Suggest: pin |
||
| 0, | ||
| ge=0, | ||
| le=5, | ||
| description="Retries on a pre-response connection reset (0=disabled, max 5)", | ||
| ) | ||
|
|
||
| # Minimum required connections for http-client to initialize. | ||
| # Will log warning if not enough ephemeral ports are available during warmup. | ||
| # | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,8 @@ | |
| from typing import Any | ||
| from urllib.parse import urlparse | ||
|
|
||
| import msgspec | ||
|
|
||
| from inference_endpoint.async_utils.transport import ( | ||
| ReceiverTransport, | ||
| SenderTransport, | ||
|
|
@@ -378,14 +380,64 @@ async def _fire_request(self, req: InFlightRequest) -> bool: | |
| 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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Suggest flagging retried samples in |
||
| """Read response headers, retrying only on a true zero-byte close. | ||
|
|
||
| A connection-level failure here can mean the server closed the socket | ||
| before writing any response byte (the idle keep-alive race: an idle | ||
| ``-np 1`` connection dropped just as the client writes on it). Only a | ||
| true zero-byte close is retried — re-issuing on a fresh connection is | ||
| safe for *client* output because the accumulator has produced nothing. | ||
|
|
||
| If any response byte already arrived (partial status-line/header bytes | ||
| before the close), the server may have received and processed the | ||
| request, so a re-issue would duplicate server-side generation work; that | ||
| case surfaces the error instead of retrying. Each recovery is logged and | ||
| counted on ``req.transport_retries`` so the successful sample carries a | ||
| marker downstream. Returns the HTTP status code, or None if the reset is | ||
| not retryable or retries are exhausted (an error response was emitted). | ||
| """ | ||
| while True: | ||
| conn = req.connection | ||
| try: | ||
| status_code, _ = await conn.protocol.read_headers() | ||
| return status_code | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( Suggest: |
||
| except ConnectionError as e: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggest tracking whether any response bytes were received and only retrying true zero-byte closes; scope the docstring claim to client output. |
||
| partial = conn.protocol.bytes_received | ||
| self._pool.release(conn) | ||
| if ( | ||
| partial | ||
| or req.transport_retries >= self.http_config.transport_max_retries | ||
| ): | ||
| await self._handle_error(req.query_id, e) | ||
| return None | ||
| req.transport_retries += 1 | ||
| logger.warning( | ||
| "Request %s: retrying after pre-response connection reset " | ||
| "(attempt %d/%d): %s", | ||
| req.query_id, | ||
| req.transport_retries, | ||
| self.http_config.transport_max_retries, | ||
| e, | ||
| ) | ||
| # Attach the fresh connection to the request BEFORE writing, so | ||
| # a write failure still leaves req.connection pointing at it for | ||
| # the caller's finally-block release (no leaked connection). | ||
| new_conn = await self._pool.acquire() | ||
| req.connection = new_conn | ||
| new_conn.protocol.write(req.http_bytes) | ||
|
|
||
| @profile | ||
| async def _process_response(self, req: InFlightRequest) -> None: | ||
| """Process response for a fired request.""" | ||
| conn = req.connection | ||
|
|
||
| try: | ||
| # Await headers and handle error status | ||
| status_code, _ = await conn.protocol.read_headers() | ||
| # Await headers, retrying on a pre-response connection reset. | ||
| status_code = await self._read_headers_with_retry(req) | ||
| if status_code is None: | ||
| # Retries exhausted; error already emitted. | ||
| return | ||
|
|
||
| conn = req.connection | ||
| if status_code != 200: | ||
| error_body = await conn.protocol.read_body() | ||
| self._pool.release(conn) | ||
|
|
@@ -406,8 +458,9 @@ async def _process_response(self, req: InFlightRequest) -> None: | |
| logger.warning(f"Request {req.query_id} failed: {type(e).__name__}: {e}") | ||
|
|
||
| finally: | ||
| # Release connection back to pool if not already | ||
| 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Council: Claude + manual trace] medium — "idempotent" is inaccurate for a dead connection
Suggest either resetting |
||
|
|
||
| # Clean up task reference | ||
| current_task = asyncio.current_task() | ||
|
|
@@ -432,7 +485,7 @@ async def _handle_streaming_body(self, req: InFlightRequest) -> None: | |
| self._pool.release(conn) | ||
|
|
||
| # Send final complete back to main rank | ||
| self._responses.send(accumulator.get_final_output()) | ||
| self._responses.send(self._tag_retries(accumulator.get_final_output(), req)) | ||
|
|
||
| @profile | ||
| async def _handle_non_streaming_body(self, req: InFlightRequest) -> None: | ||
|
|
@@ -450,7 +503,20 @@ async def _handle_non_streaming_body(self, req: InFlightRequest) -> None: | |
| result = self._adapter.decode_response(response_bytes, query_id) | ||
|
|
||
| # Send result back to main rank | ||
| self._responses.send(result) | ||
| self._responses.send(self._tag_retries(result, req)) | ||
|
|
||
| @staticmethod | ||
| def _tag_retries(result: QueryResult, req: InFlightRequest) -> QueryResult: | ||
| """Stamp a successful result with this request's retry count. | ||
|
|
||
| No-op when the request was not retried (the common path), so the hot | ||
| path pays nothing unless a pre-response reset was actually recovered. | ||
| """ | ||
| if req.transport_retries: | ||
| msgspec.structs.force_setattr( | ||
| result, "transport_retries", req.transport_retries | ||
| ) | ||
| return result | ||
|
|
||
| async def _handle_error(self, query_id: str, error: Exception | str) -> None: | ||
| """Send error response for a query.""" | ||
|
|
||
There was a problem hiding this comment.
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