Skip to content

Commit 7f19d2a

Browse files
rasmusfaberclaude
andcommitted
Stop a blocked event loop from failing completed connections
Provider SDK defaults assume a caller whose event loop is responsive. An inspect runner's is not: long CPU-bound sections hold it for seconds at a time, and a 5s connect deadline that expires during one of those blocks fails a connection the kernel had already completed, because anyio's cancellation lands on the first checkpoint after the loop resumes. The result is a run peppered with `APIConnectionError`, which inspect classifies as transient and retries, so the symptom is a slow eval rather than an obvious failure. Provider HTTP clients now allow 60s for connection setup rather than 5s, keep a keepalive pool as large as the connection limit, retry connection establishment once, and carry the TCP keepalive socket options the Anthropic SDK sets on its own. A partial raise of the deadline buys nothing: a 26s block failed 75% of connects at 5s and 76.7% at 15s, and 0% at 30s. `_util/http_defaults` holds the rationale and the measurements behind each value. This reaches the OpenAI, OpenAI-compatible, Anthropic and Groq providers, Google under trio, and the image fetches that run on the eval loop beside a generate. It deliberately does not reach the rest, which are not exposed to the problem rather than being overlooked: Bedrock, SageMaker and Azure AI have their own connection deadlines, already at or above 60s; Google on asyncio passes `aiohttp.ClientTimeout(total=...)` per request, which carries no connect deadline at all; and Grok speaks gRPC, establishing connections off the event loop where the block cannot reach them. Three things had to be handled to get the deadline as far as the socket. SDKs stamp their own scalar timeout into `request.extensions` after the client is built, and httpx expands a bare float to all four deadlines, so a budget such as `-M client_timeout=30` or the 900s `service_tier="flex"` budget would silently become the connect deadline too; a request event hook now floors it, raising a short deadline without shortening a deliberately long one. Supplying a transport turns off httpx's own proxy discovery, so the proxy mounts are rebuilt with the same settings, which is what keeps a proxied deployment from losing the keepalive probes the SDK client would have carried. And httpx applies `verify`, `cert`, `trust_env`, `http1` and `http2` only to a transport it builds itself, so those are forwarded rather than silently dropped. Six `INSPECT_HTTP_*` variables tune these without a release, documented with their reach in `docs/models-concurrency.qmd`. A provider's own default is a starting point rather than a refusal to be configured: Groq keeps its tighter request budget and uncapped pool until an operator sets the variable, which matters most there because its pool is the unbounded one. An unusable value keeps the provider's default rather than falling back to the global one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 16d6e6f)
1 parent eeb9f0e commit 7f19d2a

12 files changed

Lines changed: 966 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- Fixed duplicated task prompt and redundant context sent to the model when using `CompactionAuto` with providers that support native compaction. (#4804)
55
- Transcripts now show which compaction strategy actually ran, and flag when `CompactionAuto` fell back from native to summary compaction. (#4804)
66
- Logging: Reduced memory usage and event-loop stalls when finalizing long samples with realtime logging; summary-only hooks can opt out of full-sample materialization via `Hooks.needs_full_sample`. (#4879)
7+
- Models: OpenAI, OpenAI-compatible, Anthropic and Groq now allow 60s rather than 5s for connection setup, so a busy event loop is much less likely to cause `APIConnectionError`. (METR/inspect_ai `faber/http-connect-defaults`, not yet filed upstream)
78

89
## 0.3.258 (11 August 2026)
910

docs/models-concurrency.qmd

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,39 @@ Increasing the max connections might yield better performance due to higher para
179179
Since it can be difficult to tune this value (especially across different times of day), you are generally much better off using [Adaptive Connections](#adaptive-connections) which will dynamically find the maximum throughput that can be supported.
180180

181181

182+
## HTTP Client Settings
183+
184+
The settings above govern how many requests Inspect has in flight. Separately, the HTTP client underneath each provider has its own connection settings, tunable by environment variable so a deployment can adjust them without a release.
185+
186+
The defaults differ from the provider SDKs' in one respect that matters for long-running evals. SDK defaults assume a responsive event loop, but an eval's loop is held for seconds at a time by CPU-bound work, and a connect deadline that expires during one of those pauses fails a connection the kernel had already completed. Inspect therefore allows 60s for connection setup rather than the 5s most SDKs use, and retries connection establishment once.
187+
188+
| Variable | Default | Effect |
189+
|-----------------|-----------------|--------------------------------------|
190+
| `INSPECT_HTTP_CONNECT_TIMEOUT` | 60 | Seconds allowed to establish a connection. |
191+
| `INSPECT_HTTP_REQUEST_TIMEOUT` | 600 | Seconds allowed for the rest of a request. |
192+
| `INSPECT_HTTP_POOL_CONNECTIONS` | 1000 | Sockets in the HTTP connection pool. Distinct from `--max-connections`, which bounds requests in flight. |
193+
| `INSPECT_HTTP_POOL_KEEPALIVE_CONNECTIONS` | the connection pool size | Idle connections kept for reuse. |
194+
| `INSPECT_HTTP_KEEPALIVE_EXPIRY` | 5 | Seconds an idle connection is kept. |
195+
| `INSPECT_HTTP_CONNECT_RETRIES` | 1 | Retries of connection establishment. Only the connect is retried, never the request, so this is safe for non-idempotent calls. Unavailable through a proxy, which httpx does not retry. |
196+
197+
Two of these interact with settings a provider may already have:
198+
199+
- A request budget you set yourself, via `-M client_timeout`, `-M timeout` or `service_tier="flex"`, is kept. `INSPECT_HTTP_CONNECT_TIMEOUT` acts as a floor alongside it: it raises a connect deadline shorter than the floor, and leaves a longer one alone.
200+
201+
- A provider whose SDK default is deliberately different keeps that default until you set the variable. Groq, for example, uses a tighter request budget and an unbounded connection pool; setting `INSPECT_HTTP_REQUEST_TIMEOUT` or `INSPECT_HTTP_POOL_CONNECTIONS` overrides it.
202+
203+
These settings reach the OpenAI, OpenAI-compatible (including vLLM, SGLang, Ollama, OpenRouter, Together and the other `openai-api/` services), Anthropic and Groq providers.
204+
205+
Google under Trio gets the pool and connect-retry settings but not the deadlines: it applies its own overall budget to every request, which is already far longer than the connect floor.
206+
207+
They do not reach the remaining providers, which are on other HTTP stacks and are not exposed to the problem these defaults address:
208+
209+
- Bedrock and SageMaker have their own connection deadline, already 60s, set per model with `-M connect_timeout`. Azure AI likewise uses its own, which is longer still.
210+
211+
- Google on asyncio applies a single overall budget with no separate connection deadline, so there is none to outlast. (Under Trio it uses a different HTTP stack, which is why the settings apply there.)
212+
213+
- Grok speaks gRPC and establishes connections off the event loop, where the pause that motivates these defaults cannot affect it.
214+
182215
## Learning More
183216

184217
- [Parallelism](parallelism.qmd): running multiple tasks or models in parallel, sandbox container concurrency, and writing parallel custom code.
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
"""Shared HTTP client defaults for provider SDK clients.
2+
3+
The SDK defaults are tuned for a caller whose event loop is responsive. An
4+
inspect runner's loop is not: long CPU-bound sections (payload transforms,
5+
transcript scans) hold it for seconds at a time, and a connect deadline that
6+
expires during one of those blocks fails a connection that the kernel already
7+
completed — anyio's cancellation lands on the first checkpoint after the loop
8+
resumes, so a finished connect is no defence.
9+
10+
Three defaults follow, all overridable by environment variable:
11+
12+
* ``connect`` at 60s rather than 5s. A partial raise buys nothing (a 26s block
13+
failed 75% at 5s and 76.7% at 15s, 0% at 30s), and the deadline has to clear
14+
*several* blocks: a connect that misses its window waits out whole blocks, so
15+
failure latency quantises to multiples of the block length (a 3s block
16+
against a 5s deadline failed 77% of the time, median 6.1s). The cost is that
17+
a dead endpoint takes 120s to detect, since the retry below gives it a second
18+
full deadline; httpcore charges the deadline separately to the TCP connect
19+
and the TLS handshake, so a slow-then-stalling endpoint can reach 4x, and an
20+
SDK that retries multiplies that again.
21+
22+
* ``max_keepalive_connections`` at the connection limit rather than 100. Above
23+
the cap every connection is destroyed the moment it goes idle, stepping the
24+
new-connection rate ~20x once concurrency passes ~120, and only new
25+
connections run the connect path at all.
26+
27+
* One connection-establishment retry. httpcore retries the connect only and
28+
never re-sends the request, so it is safe for non-idempotent calls. It needs
29+
the loop running again by the time it fires, so it rescues a lightly loaded
30+
caller and does little for a saturated one.
31+
32+
The transport also carries the TCP keepalive socket options the Anthropic SDK
33+
sets on its own, since they live on the transport and supplying one drops them.
34+
Supplying a transport likewise turns off httpx's environment proxy discovery,
35+
so the proxy mounts are rebuilt here and given the same settings.
36+
37+
``keepalive_expiry`` stays at httpx's 5s default: raising it past an
38+
intermediary's idle timeout (an ALB commonly defaults to 60s) trades these
39+
failures for stale-connection ones. Note this leaves the
40+
``max_keepalive_connections`` raise inert whenever blocks outlast the expiry,
41+
which is the case that motivated it (at a 6s block, 100 versus 1000 changed
42+
nothing, both failing every request). A deployment that knows its own topology
43+
should raise the expiry alongside the cap.
44+
"""
45+
46+
from __future__ import annotations
47+
48+
import os
49+
import socket
50+
from typing import Any, overload
51+
52+
import httpx
53+
54+
55+
def _no_environment_proxies() -> dict[str, str | None]:
56+
return {}
57+
58+
59+
try:
60+
# Private to httpx, but replicating its NO_PROXY rules would drift; the
61+
# anthropic SDK vendors a copy for the same reason. Degrade to no mounts
62+
# rather than failing to import if httpx moves it.
63+
from httpx._utils import get_environment_proxies as _get_environment_proxies
64+
except ImportError: # pragma: no cover - httpx moved its internals
65+
_get_environment_proxies = _no_environment_proxies
66+
67+
DEFAULT_CONNECT_TIMEOUT = 60.0
68+
DEFAULT_REQUEST_TIMEOUT = 600.0
69+
DEFAULT_POOL_CONNECTIONS = 1000
70+
DEFAULT_KEEPALIVE_EXPIRY = 5.0
71+
DEFAULT_CONNECT_RETRIES = 1
72+
73+
CONNECT_TIMEOUT_ENV = "INSPECT_HTTP_CONNECT_TIMEOUT"
74+
REQUEST_TIMEOUT_ENV = "INSPECT_HTTP_REQUEST_TIMEOUT"
75+
POOL_CONNECTIONS_ENV = "INSPECT_HTTP_POOL_CONNECTIONS"
76+
POOL_KEEPALIVE_CONNECTIONS_ENV = "INSPECT_HTTP_POOL_KEEPALIVE_CONNECTIONS"
77+
KEEPALIVE_EXPIRY_ENV = "INSPECT_HTTP_KEEPALIVE_EXPIRY"
78+
CONNECT_RETRIES_ENV = "INSPECT_HTTP_CONNECT_RETRIES"
79+
80+
81+
def _env_float(name: str, fallback: float) -> float:
82+
"""A non-negative float from the environment, `fallback` on unset or junk."""
83+
raw = os.environ.get(name)
84+
if raw is None or not raw.strip():
85+
return fallback
86+
try:
87+
value = float(raw)
88+
except ValueError:
89+
return fallback
90+
return value if value >= 0 else fallback
91+
92+
93+
@overload
94+
def _env_int(name: str, fallback: int) -> int: ...
95+
96+
97+
@overload
98+
def _env_int(name: str, fallback: None) -> int | None: ...
99+
100+
101+
def _env_int(name: str, fallback: int | None) -> int | None:
102+
"""A non-negative int from the environment, `fallback` on unset or junk."""
103+
raw = os.environ.get(name)
104+
if raw is None or not raw.strip():
105+
return fallback
106+
try:
107+
value = int(raw)
108+
except ValueError:
109+
return fallback
110+
return value if value >= 0 else fallback
111+
112+
113+
def connect_timeout() -> float:
114+
return _env_float(CONNECT_TIMEOUT_ENV, DEFAULT_CONNECT_TIMEOUT)
115+
116+
117+
def default_timeout(
118+
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
119+
) -> httpx.Timeout:
120+
"""Timeouts, with the environment overriding `request_timeout`.
121+
122+
The argument is only the fallback, so a provider whose SDK has a budget of
123+
its own can pass it and keep it until an operator sets the variable.
124+
"""
125+
return httpx.Timeout(
126+
timeout=_env_float(REQUEST_TIMEOUT_ENV, request_timeout),
127+
connect=connect_timeout(),
128+
)
129+
130+
131+
def default_limits(
132+
max_connections: int | None = DEFAULT_POOL_CONNECTIONS,
133+
) -> httpx.Limits:
134+
"""Pool limits, with the environment overriding `max_connections`.
135+
136+
The argument is only the fallback, so a provider whose pool is deliberately
137+
uncapped can pass None and keep it until an operator sets the variable.
138+
"""
139+
connections = _env_int(POOL_CONNECTIONS_ENV, max_connections)
140+
return httpx.Limits(
141+
max_connections=connections,
142+
# Tracks the pool size unless set outright, so lowering the limit
143+
# cannot leave a keepalive cap above it.
144+
max_keepalive_connections=_env_int(POOL_KEEPALIVE_CONNECTIONS_ENV, connections),
145+
keepalive_expiry=_env_float(KEEPALIVE_EXPIRY_ENV, DEFAULT_KEEPALIVE_EXPIRY),
146+
)
147+
148+
149+
def _default_socket_options() -> list[tuple[int, int, int]]:
150+
"""TCP keepalive options matching the ones the Anthropic SDK installs."""
151+
options: list[tuple[int, int, int]] = [
152+
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, True)
153+
]
154+
# Not every knob exists on every platform.
155+
for name, value in (
156+
("TCP_KEEPINTVL", 60),
157+
("TCP_KEEPCNT", 5),
158+
("TCP_KEEPIDLE", 60),
159+
):
160+
option = getattr(socket, name, None)
161+
if isinstance(option, int):
162+
options.append((socket.IPPROTO_TCP, option, value))
163+
return options
164+
165+
166+
async def _floor_connect_timeout(request: httpx.Request) -> None:
167+
"""Stop a scalar SDK timeout from shortening the connect deadline.
168+
169+
SDKs stamp their own per-request timeout over whatever the client was built
170+
with, and httpx expands a bare float to all four phases, so an overall
171+
budget such as `-M client_timeout=30` would become the connect deadline
172+
too. Only ever raises it, leaving a longer deadline and a `None` (no limit)
173+
alone.
174+
"""
175+
extension = request.extensions.get("timeout")
176+
if not isinstance(extension, dict):
177+
return
178+
# Safe to mutate: httpx.Timeout.as_dict() builds this fresh per request.
179+
timeout: dict[str, float | None] = extension
180+
connect = timeout.get("connect")
181+
floor = connect_timeout()
182+
if connect is not None and connect < floor:
183+
timeout["connect"] = floor
184+
185+
186+
def _transport_kwargs(
187+
limits: httpx.Limits, overrides: dict[str, Any]
188+
) -> dict[str, Any]:
189+
"""Settings httpx would apply itself if it were building the transport."""
190+
kwargs: dict[str, Any] = {
191+
"retries": _env_int(CONNECT_RETRIES_ENV, DEFAULT_CONNECT_RETRIES),
192+
"limits": limits,
193+
"socket_options": _default_socket_options(),
194+
}
195+
# httpx applies these only to a transport it builds itself, so a caller
196+
# passing verify=... would otherwise have it silently dropped.
197+
kwargs.update(
198+
{
199+
arg: overrides[arg]
200+
for arg in ("verify", "cert", "trust_env", "http1", "http2")
201+
if arg in overrides
202+
}
203+
)
204+
return kwargs
205+
206+
207+
def default_client_kwargs(**overrides: Any) -> dict[str, Any]:
208+
"""`httpx.AsyncClient` kwargs carrying these defaults; caller overrides win."""
209+
kwargs = dict(overrides)
210+
# `limits` resolves first because httpx applies `limits=` only to a
211+
# transport it builds itself, so the transport has to bake them in.
212+
limits = kwargs.setdefault("limits", default_limits())
213+
kwargs.setdefault("timeout", default_timeout())
214+
kwargs.setdefault("follow_redirects", True)
215+
216+
if "transport" not in kwargs:
217+
transport_kwargs = _transport_kwargs(limits, kwargs)
218+
# Supplying a transport turns off httpx's environment proxy discovery,
219+
# so rebuild the mounts with the same settings rather than losing
220+
# either the proxy or the retry and keepalive options.
221+
mounts: dict[str, httpx.AsyncBaseTransport | None] = {
222+
key: None
223+
if url is None
224+
else httpx.AsyncHTTPTransport(proxy=httpx.Proxy(url), **transport_kwargs)
225+
for key, url in _get_environment_proxies().items()
226+
}
227+
mounts.update(kwargs.get("mounts") or {})
228+
kwargs["mounts"] = mounts
229+
kwargs["transport"] = httpx.AsyncHTTPTransport(**transport_kwargs)
230+
231+
hooks = dict(kwargs.get("event_hooks") or {})
232+
hooks["request"] = [_floor_connect_timeout, *hooks.get("request", [])]
233+
kwargs["event_hooks"] = hooks
234+
return kwargs
235+
236+
237+
def default_async_client(**overrides: Any) -> httpx.AsyncClient:
238+
return httpx.AsyncClient(**default_client_kwargs(**overrides))

src/inspect_ai/_util/images.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55
from typing import Awaitable, Callable, Iterator
66
from urllib.parse import urlparse
77

8-
import httpx
9-
108
from .file import file as open_file
9+
from .http_defaults import default_async_client
1110
from .url import (
1211
data_uri_mime_type,
1312
data_uri_to_base64,
@@ -103,7 +102,9 @@ async def file_as_data(file: str) -> tuple[bytes, str]:
103102

104103
# handle url or file
105104
if is_http_url(file):
106-
async with httpx.AsyncClient() as client:
105+
# Fetched on the eval loop alongside model requests, so it needs
106+
# the same connect deadline.
107+
async with default_async_client() as client:
107108
file_bytes = (await client.get(file)).content
108109
else:
109110
with open_file(file, "rb") as f:

src/inspect_ai/model/_openai.py

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
is_retryable_http_status,
6060
parse_retry_after_from_exception,
6161
)
62+
from inspect_ai._util.http_defaults import default_client_kwargs
6263
from inspect_ai._util.images import file_as_data_uri
6364
from inspect_ai._util.url import is_http_url
6465
from inspect_ai.model._call_tools import parse_tool_call
@@ -1222,30 +1223,14 @@ def openai_media_filter(key: JsonValue | None, value: JsonValue) -> JsonValue:
12221223
return value
12231224

12241225

1225-
# httpx-native equivalents of openai's DEFAULT_TIMEOUT / DEFAULT_CONNECTION_LIMITS
1226-
# (same values). Do NOT import those from `openai`: openai >= 3.0 is built on
1227-
# httpx2, and its httpx2-typed constants silently corrupt the timeout config of
1228-
# our legacy `httpx.AsyncClient`, failing every request with APIConnectionError.
1229-
DEFAULT_TIMEOUT = httpx.Timeout(timeout=600, connect=5.0)
1230-
DEFAULT_CONNECTION_LIMITS = httpx.Limits(
1231-
max_connections=1000, max_keepalive_connections=100
1232-
)
1233-
1234-
12351226
class OpenAIAsyncHttpxClient(httpx.AsyncClient):
1236-
"""Custom async client that uses OpenAI's default settings.
1237-
1238-
This ensures proper proxy support and follows OpenAI's recommended configuration.
1239-
OpenAI has already incorporated timeout improvements for reasoning models in their
1240-
default transport, so we don't need custom socket options.
1227+
"""Async client carrying the shared HTTP defaults (see `_util.http_defaults`).
12411228
1229+
Do NOT source these from `openai`: openai >= 3.0 is built on httpx2, and
1230+
its httpx2-typed `DEFAULT_TIMEOUT` / `DEFAULT_CONNECTION_LIMITS` silently
1231+
corrupt the timeout config of this legacy `httpx.AsyncClient`, failing
1232+
every request with APIConnectionError.
12421233
"""
12431234

12441235
def __init__(self, **kwargs: Any) -> None:
1245-
# Use OpenAI's default settings which handle proxies correctly
1246-
# https://github.com/openai/openai-python/commit/347363ed67a6a1611346427bb9ebe4becce53f7e
1247-
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
1248-
kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
1249-
kwargs.setdefault("follow_redirects", True)
1250-
1251-
super().__init__(**kwargs)
1236+
super().__init__(**default_client_kwargs(**kwargs))

0 commit comments

Comments
 (0)