Skip to content

[tinker] Forward samples with aiohttp instead of httpx - #2161

Open
avigyabb wants to merge 2 commits into
avi/stack-1-access-logfrom
avi/stack-2-aiohttp-forwarding
Open

[tinker] Forward samples with aiohttp instead of httpx#2161
avigyabb wants to merge 2 commits into
avi/stack-1-access-logfrom
avi/stack-2-aiohttp-forwarding

Conversation

@avigyabb

@avigyabb avigyabb commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Stack 2/7.

Why. httpcore's connection pool rescans every pooled connection and queued request on each event, so the forwarding client's per-request CPU grows with the number of in-flight samples: 14 ms at 512, 27 ms at 2048, 38 ms at 4096 (standalone benchmark against an instant fake router; the profile shows 27.5M is_idle calls for 512 requests). At 2048 in flight the API server needed 119 s to forward 2048 instant requests. aiohttp's connector is flat at 0.34 ms.

Change. SkyRLTrainInferenceForwardingClient uses one aiohttp session:

  • connector limit = forwarding_inference_max_connections (0 = unlimited); sock_read = forwarding_inference_timeout_sec; no total deadline, so requests queued behind the engine never hit the old 300 s pool timeout;
  • 60 s connect timeout (a saturated router takes tens of seconds to accept; that is queueing, not failure) and Happy Eyeballs off: a wave of cancelled connects under uvloop produced ~800 File descriptor N is used by transport errors from aiohappyeyeballs' sock_connect, failing unrelated forwards;
  • connect-phase errors and 5xx rejections (TransientInferenceError) are retried once after refreshing the proxy URL; read failures stay final (fix(tinker): bound inference forwarding retries #2118 stance) since vLLM may still be executing the request;
  • forwarding_inference_timeout_sec default 300 s -> 2048 s: with unlimited connections a large burst waits inside vLLM's queue and 128x128 bursts exceed 300 s there.

Results (same harness and SDK-shaped client, upstream main vs this stack): 2048 small results unlimited connections 71.6 s / 29 per s -> 2.7 s / 747 per s; 32768 with 5 s engine queueing 218 s with 530 failures -> 89 s, 0 failures.

Note: unlimited outbound connections cannot exceed ~28k (one source IP's ephemeral ports); set --forwarding-inference-max-connections near engine capacity for very large bursts.

Stack (each PR retargets to main as the one below merges)

  1. [tinker] Route uvicorn's access log to a plain handler instead of Rich #2160
  2. [tinker] Forward samples with aiohttp instead of httpx #2161
  3. [tinker] Keep an undelivered sample result alive for the SDK's retry #2162
  4. [tinker] Survive completion bursts at the socket layer (accept backlog, keep-alive) #2163
  5. [tinker] Encode forwarded sample results to proto once and serve them as-is #2164
  6. [tinker] Decode vLLM completion bodies straight into numpy with pysimdjson #2165
  7. [tinker] Load harness for the API server's sampling path at 131k concurrency #2166

🤖 Generated with Claude Code


Note

Medium Risk
Touches hot-path inference forwarding, connection/retry semantics, and future eviction timing under load; mis-tuned timeouts or disconnect handling could cause duplicate forwards or 404s on SDK retries.

Overview
Replaces httpx with aiohttp in SkyRLTrainInferenceForwardingClient so high fan-out rollouts don’t pay growing per-request pool CPU; the shared session uses connector limits, a 60s connect timeout, no total deadline (queueing waits in the connector), Happy Eyeballs off under uvloop, and orjson for response bodies. Retries refresh the proxy URL once on connect failures and on router 5xx (TransientInferenceError); read timeouts stay non-retryable.

forwarding_inference_timeout_sec default rises 300 → 2048 to cover long vLLM queue waits on large bursts.

Uvicorn gets timeout_keep_alive=75 and backlog=SKYRL_HTTP_CONNECTION_LIMIT so SDK polls survive completion bursts without mass reconnect/refused accepts.

retrieve_future only calls mark_retrieved when the client is still connected, so abandoned 45s SDK polls don’t start the short TTL on undelivered results; _RETRIEVED_TTL_SECONDS is 120 → 300 to cover SDK retry/backoff. Integration tests cover the abandoned-poll → retry path over a real socket.

Reviewed by Cursor Bugbot for commit 3bdbb3d. Bugbot is set up for automated code reviews on this repo. Configure here.

httpcore's connection pool rescans every pooled connection and queued request
on each event, so the forwarding client's per-request CPU grew with the number
of in-flight samples: 14ms at 512, 27ms at 2048, 38ms at 4096 (a standalone
benchmark against an instant fake router; profile shows 27.5M is_idle calls
for 512 requests). At 2048 in flight the API server needed 119s to forward
2048 instant requests. aiohttp's connector is flat at 0.34ms per request.

- SkyRLTrainInferenceForwardingClient uses one aiohttp session: connector
  limit = forwarding_inference_max_connections (0 = unlimited), sock_read =
  forwarding_inference_timeout_sec, no total deadline so requests queued
  behind the engine never hit the old 300s pool timeout, 60s connect timeout
  (a saturated router takes tens of seconds to accept), Happy Eyeballs off
  (a wave of cancelled connects left uvloop "File descriptor N is used by
  transport" errors from aiohappyeyeballs).
- Connect-phase errors and 5xx rejections from the router
  (TransientInferenceError) are retried once after refreshing the proxy URL;
  read failures stay final since vLLM may still be executing the request.
- forwarding_inference_timeout_sec default 300s -> 2048s: with unlimited
  connections a large rollout burst waits inside vLLM's queue and 128x128
  bursts exceed 300s there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>

@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 replaces httpx with aiohttp and orjson in the inference forwarding client to improve performance and connection handling under high concurrency, and increases the default forwarding timeout to 2048 seconds. The reviewer identified a critical issue where the code attempts to catch non-existent exceptions (aiohttp.ConnectionTimeoutError and aiohttp.SocketTimeoutError), which would lead to runtime AttributeErrors. To resolve this, the reviewer suggested defining custom exceptions and wrapping the connection establishment and response reading in separate try-except blocks to properly distinguish between connection and read timeouts (as both raise asyncio.TimeoutError in aiohttp), as well as updating the corresponding tests.

Comment on lines +23 to +27
class TransientInferenceError(RuntimeError):
"""A 5xx from vllm-router/vLLM: the request was rejected, not executed, so it is safe to retry."""


_ROUTER_CONNECT_TIMEOUT_SECONDS = 60.0

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

The aiohttp library does not define ConnectionTimeoutError or SocketTimeoutError exceptions. Referencing them will raise an AttributeError at runtime.

To correctly handle and distinguish connection timeouts from read timeouts, we can define custom ConnectionTimeoutError and ReadTimeoutError exceptions.

class TransientInferenceError(RuntimeError):
    """A 5xx from vllm-router/vLLM: the request was rejected, not executed, so it is safe to retry."""


class ConnectionTimeoutError(RuntimeError):
    """Connection timed out."""


class ReadTimeoutError(RuntimeError):
    """Read timed out."""


_ROUTER_CONNECT_TIMEOUT_SECONDS = 60.0

Comment on lines +159 to +168
except (aiohttp.ClientConnectorError, aiohttp.ConnectionTimeoutError, TransientInferenceError) as e:
logger.warning(
"Connection error talking to %s (%s: %s) — refreshing proxy URL and retrying once",
"Transient error talking to %s (%s: %s) — refreshing proxy URL and retrying once",
self._cached_proxy_url,
type(e).__name__,
e,
)
proxy_url = await self._resolve_proxy_url(force_refresh=True)
return await self._forward(proxy_url, sample_req, model_id, base_model=base_model)
except httpx.ReadTimeout as e:
except aiohttp.SocketTimeoutError as e:

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

Catch the custom ConnectionTimeoutError and ReadTimeoutError exceptions instead of the non-existent aiohttp.ConnectionTimeoutError and aiohttp.SocketTimeoutError.

Suggested change
except (aiohttp.ClientConnectorError, aiohttp.ConnectionTimeoutError, TransientInferenceError) as e:
logger.warning(
"Connection error talking to %s (%s: %s) — refreshing proxy URL and retrying once",
"Transient error talking to %s (%s: %s) — refreshing proxy URL and retrying once",
self._cached_proxy_url,
type(e).__name__,
e,
)
proxy_url = await self._resolve_proxy_url(force_refresh=True)
return await self._forward(proxy_url, sample_req, model_id, base_model=base_model)
except httpx.ReadTimeout as e:
except aiohttp.SocketTimeoutError as e:
except (aiohttp.ClientConnectorError, ConnectionTimeoutError, TransientInferenceError) as e:
logger.warning(
"Transient error talking to %s (%s: %s) — refreshing proxy URL and retrying once",
self._cached_proxy_url,
type(e).__name__,
e,
)
proxy_url = await self._resolve_proxy_url(force_refresh=True)
return await self._forward(proxy_url, sample_req, model_id, base_model=base_model)
except ReadTimeoutError as e:

Comment on lines +228 to +243
async with self._get_session().post(url, json=payload, headers=headers) as response:
body = await response.read()
if response.status >= 500:
raise TransientInferenceError(
f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}"
)
if response.status >= 400:
raise RuntimeError(f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}")
try:
result = orjson.loads(body)
except orjson.JSONDecodeError as e:
# vllm-router can return HTML on transient errors even with 2xx status.
raise RuntimeError(
f"vLLM /v1/completions returned non-JSON ({response.status}, "
f"content-type={response.headers.get('content-type')!r}): {body[:512].decode(errors='replace')}"
) from e

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

In aiohttp, both connection timeouts and read timeouts raise asyncio.TimeoutError. To distinguish them, we can wrap the connection establishment (async with self._get_session().post(...)) and the response reading (await response.read()) in separate try...except blocks, raising the custom ConnectionTimeoutError and ReadTimeoutError respectively.

        try:
            async with self._get_session().post(url, json=payload, headers=headers) as response:
                try:
                    body = await response.read()
                except asyncio.TimeoutError as e:
                    raise ReadTimeoutError("Read timeout") from e
                if response.status >= 500:
                    raise TransientInferenceError(
                        f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}"
                    )
                if response.status >= 400:
                    raise RuntimeError(f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}")
                try:
                    result = orjson.loads(body)
                except orjson.JSONDecodeError as e:
                    # vllm-router can return HTML on transient errors even with 2xx status.
                    raise RuntimeError(
                        f"vLLM /v1/completions returned non-JSON ({response.status}, "
                        f"content-type={response.headers.get('content-type')!r}): {body[:512].decode(errors='replace')}"
                    ) from e
        except asyncio.TimeoutError as e:
            raise ConnectionTimeoutError("Connection timeout") from e

Comment on lines 9 to 12
from skyrl.tinker.extra.skyrl_train_inference_forwarding import (
SkyRLTrainInferenceForwardingClient,
TransientInferenceError,
)

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.

medium

Import the custom ConnectionTimeoutError and ReadTimeoutError exceptions for testing.

Suggested change
from skyrl.tinker.extra.skyrl_train_inference_forwarding import (
SkyRLTrainInferenceForwardingClient,
TransientInferenceError,
)
from skyrl.tinker.extra.skyrl_train_inference_forwarding import (
SkyRLTrainInferenceForwardingClient,
TransientInferenceError,
ConnectionTimeoutError,
ReadTimeoutError,
)

Comment on lines +107 to +113
client._forward = AsyncMock(side_effect=aiohttp.SocketTimeoutError("slow response"))

with pytest.raises(RuntimeError) as exc_info:
await client._forward_with_retry(object(), "model", base_model=None)

message = str(exc_info.value)
assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout)
assert isinstance(exc_info.value.__cause__, aiohttp.SocketTimeoutError)

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.

medium

Update the test to use the custom ReadTimeoutError instead of the non-existent aiohttp.SocketTimeoutError.

Suggested change
client._forward = AsyncMock(side_effect=aiohttp.SocketTimeoutError("slow response"))
with pytest.raises(RuntimeError) as exc_info:
await client._forward_with_retry(object(), "model", base_model=None)
message = str(exc_info.value)
assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout)
assert isinstance(exc_info.value.__cause__, aiohttp.SocketTimeoutError)
client._forward = AsyncMock(side_effect=ReadTimeoutError("slow response"))
with pytest.raises(RuntimeError) as exc_info:
await client._forward_with_retry(object(), "model", base_model=None)
message = str(exc_info.value)
assert isinstance(exc_info.value.__cause__, ReadTimeoutError)

@greptile-apps

greptile-apps Bot commented Sep 5, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

The PR should not merge until the blanket 5xx retry is narrowed or made idempotent, because a server error can occur after inference has already been accepted.

The new response handling resubmits every 5xx completion without a deduplication boundary, allowing ambiguous server failures to trigger duplicate generation work.

Files Needing Attention: skyrl/tinker/extra/skyrl_train_inference_forwarding.py

Important Files Changed

Filename Overview
skyrl/tinker/extra/skyrl_train_inference_forwarding.py Migrates forwarding to aiohttp, but the blanket retry of every 5xx can duplicate an already accepted inference request.
skyrl/tinker/config.py Raises the default forwarding read timeout and documents why large queued bursts require it.
tests/tinker/test_inference_forwarding_config.py Updates timeout, connector-limit, and retry tests, though the mocked 5xx test assumes all server errors are safe to retry.

Sequence Diagram

sequenceDiagram
    participant API
    participant Router
    participant vLLM
    API->>Router: POST /v1/completions
    Router->>vLLM: Forward completion
    vLLM-->>Router: 5xx after accepting request
    Router-->>API: 5xx
    Note over API: Classify every 5xx as transient
    API->>Router: Retry same completion
    Router->>vLLM: Execute completion again
Loading

Reviews (1): Last reviewed commit: "[tinker] Forward samples with aiohttp in..." | Re-trigger Greptile

Comment on lines +230 to +233
if response.status >= 500:
raise TransientInferenceError(
f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 5xx retries duplicate inference

When vLLM or the router returns a 5xx after accepting a completion, _forward classifies it as safe to retry and submits the same non-idempotent request again, causing duplicate generation work and additional engine load.

avigyabb added a commit that referenced this pull request Sep 5, 2026
#2160)

Stack 1/7. Independent of the rest.

RichHandler renders each access-log record through a rich Table, about
1.5 ms of event-loop CPU per HTTP request. Under rollout load that was
63% of the Tinker API server's CPU: profiling a 4096-sample run showed
12.5 s of its 19.9 s of server CPU inside `rich.logging.emit`. With a
plain `StreamHandler` for `uvicorn.access` the same run takes 9.7 s and
server throughput goes from 337 to 568 samples/s. Startup and error
logging keep the Rich handler.

Measured with the load harness in stack 7/7
(`skyrl/benchmarks/load_test_tinker_sampling.py`).


**Stack** (each PR retargets to `main` as the one below merges)
1. #2160
2. #2161
3. #2162
4. #2163
5. #2164
6. #2165
7. #2166

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Logging configuration only; no request handling, auth, or data path
changes—access output format may look slightly plainer on stderr.
> 
> **Overview**
> **HTTP access logging** for the Tinker API (via
`get_uvicorn_log_config`) no longer goes through `RichHandler`. A
dedicated **`access`** `StreamHandler` on stderr uses the same text
formatter, while **`uvicorn`** and **`uvicorn.error`** still use Rich
for startup, errors, and tracebacks.
> 
> This targets per-request access log volume: Rich’s table rendering was
a major event-loop CPU cost under high QPS. Access lines should read the
same format string but without Rich styling.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
254499a. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
avigyabb added a commit that referenced this pull request Sep 5, 2026
…g, keep-alive) (#2163)

Stack 4/7.

`uvicorn.run`: `backlog=SKYRL_HTTP_CONNECTION_LIMIT` (50k, as on Chuck's
branch; the effective value is capped by `net.core.somaxconn`, raise it
to match) and `timeout_keep_alive=75`.

With 131072 outstanding samples and 212k-token results, each 2048-result
completion burst kept the event loop busy ~16 s; uvicorn's 5 s
keep-alive then closed every idle client connection, all clients
reconnected at once and the 2048-entry accept backlog overflowed,
refusing 109k of 131072 requests. With these settings the same run
completed 130917 of 131072 with zero forwarding errors and zero
reconnects (earlier runs on the 5 s keep-alive showed hundreds to
thousands).

Neither setting is needed for correctness: the SDK retries refused or
dropped connections. They avoid the reconnect storm rather than fix a
failure, and with the SDK's per-client in-flight cap the burst that
overflowed the backlog does not occur. An earlier revision of this PR
also exposed `sample_max_concurrent_requests` from `EngineConfig` via
`/client/config`; that was dropped as unnecessary (its default equalled
the SDK default, and no measured run depended on it).


**Stack** (each PR retargets to `main` as the one below merges)
1. #2160
2. #2161
3. #2162
4. #2163
5. #2164
6. #2165
7. #2166

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Server-only uvicorn listen/keep-alive defaults; no API or auth
behavior change, with SDK retries as a fallback.
> 
> **Overview**
> The Tinker API server now passes **uvicorn** socket tuning so large
completion bursts do not trigger mass client reconnects and accept-queue
overflows.
> 
> **`timeout_keep_alive`** is set to **75s** (via
`HTTP_KEEP_ALIVE_TIMEOUT_SECONDS`) instead of uvicorn’s **5s** default,
so idle SDK connections stay open while the event loop is busy for many
seconds during bursts.
> 
> **`backlog`** is set to **`SKYRL_HTTP_CONNECTION_LIMIT`** (default
**50k**, overridable by env), so pending connections queue in the kernel
instead of being refused when the loop cannot accept fast enough
(effective cap is **`net.core.somaxconn`**).
> 
> These are **reliability/performance** knobs, not correctness fixes—the
SDK already retries refused or dropped connections.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
27c838e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…2162)

Stack 3/7. Fixes the 128x128 `404 Future not found` seen against
j316chuck#18.

**Chain.** The SDK polls `retrieve_future` with a 45 s client timeout
and gives up; the result lands afterwards; the abandoned handler wakes,
builds a response nobody receives (uvicorn drops the send to a dead
client silently) and starts the short retrieved-TTL clock; the sweeper
evicts the result 120 s later; the SDK's retry of the same request_id
gets 404, which the SDK treats as fatal.

**Fix.** Start the retrieved clock only if `request.is_disconnected()`
is false, and raise the retrieved TTL to 300 s so it outlasts the SDK's
worst-case re-poll gap (45 s timeout + up to 30 s backoff, twice).
`tests/tinker/test_retrieve_future_lost_response.py` reproduces the
chain under a real uvicorn socket with shortened TTLs; it fails on
`main` and passes here. A second test checks a delivered result still
expires on the short clock, so memory stays bounded.

Alternative considered: j316chuck#19 drops the retrieved clock and
keeps every result for 2048 s. That also fixes the 404 but retains ~35
minutes of results regardless of delivery; with long-output rollouts
(hundreds of KB per result) that is tens of GB. Verified at scale:
131072 requests with 5 s engine queueing and 224k SDK-style abandoned
polls completed with zero 404s.


**Stack** (each PR retargets to `main` as the one below merges)
1. #2160
2. #2161
3. #2162
4. #2163
5. #2164
6. #2165
7. #2166

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes async polling/delivery semantics and HTTP server tuning on the
hot `retrieve_future` path; behavior is covered by new integration tests
but affects SDK retry reliability under load.
> 
> **Overview**
> Fixes fatal **`404 Future not found`** when the SDK abandons a long
`retrieve_future` poll (45s client timeout) and retries the same
`request_id` after the result is ready.
> 
> **`retrieve_future`** now calls **`mark_retrieved`** (starting the
post-delivery eviction clock) only when the client is still connected
(`not await req.is_disconnected()`). If the handler finishes building a
response after the client disconnected, the short retrieved TTL no
longer starts, so the in-memory store keeps the result for a real retry.
> 
> **`ExternalFutureStore`** raises **`_RETRIEVED_TTL_SECONDS`** from
120s to 300s so delivered results still get a grace window that covers
worst-case SDK re-poll gaps (timeout + backoff, twice).
> 
> **Uvicorn** startup sets **`timeout_keep_alive=75`** (vs 5s default)
and **`backlog=SKYRL_HTTP_CONNECTION_LIMIT`** to reduce idle disconnects
and accept-queue overflows during completion bursts.
> 
> Adds **`test_retrieve_future_lost_response.py`** (real socket on
Linux) plus test stubs for **`is_disconnected`** on existing API tests.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
36188da. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant