Skip to content

Commit 21321e3

Browse files
Reflexclaude
andcommitted
fix(upload_file): unstick concurrent uploads stalling at 30s on the server
Customers running many concurrent upload_file calls through a single SDK instance were hitting 408 "Request timed out waiting for request data" from mux/Jetty. Triage on 2026-05-14 found the request-body simply wasn't arriving within Jetty's 30 s data-wait window. Three contributing causes, all introduced by recent SDK changes: 1. DEFAULT_CONNECTION_LIMITS was lowered from (100, 20) to (20, 10) in #797. Combined with the new shared process-global pool, parallel uploads divide bandwidth across far fewer TCP connections than before and starve each other on multipart bodies. Restore the prior (100, 20). 2. _should_retry blanket-retries 408, including upload_file. Each retry pushes another large multipart body onto the same exhausted pool and amplifies the stall instead of recovering from it. Carve out upload_file 408 — other endpoints still retry 408 as before. 3. _files.py read the entire PathLike into memory via read_bytes() before the request was built, contradicting the docstring claim of streaming. Hand httpx an open file handle so the multipart encoder reads lazily and the pool slot isn't held during disk I/O. Tests updated to assert IsInstance(io.IOBase) instead of IsBytes() and to close handles. Also exposes a public connection_limits knob on Runloop / AsyncRunloop (and copy()) so customers can raise the cap above 100 for upload-heavy workloads without needing a custom httpx client. Passing connection_limits implicitly opts out of the shared pool, since the shared pool is process-global and can't honor per-client limits. Includes examples/stress_upload_file.py — a standalone repro that fires N concurrent upload_file calls and reports status-code distribution and latency percentiles, so the before/after delta is one command away. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d5303c2 commit 21321e3

8 files changed

Lines changed: 468 additions & 15 deletions

File tree

examples/stress_upload_file.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
#!/usr/bin/env -S uv run python
2+
"""Stress test for AsyncRunloop.devboxes.upload_file.
3+
4+
Reproduces the 408 "Request timed out waiting for request data" pattern seen
5+
in production when many upload_file calls run concurrently through a single
6+
SDK client (shared httpx connection pool).
7+
8+
Usage:
9+
10+
export RUNLOOP_API_KEY=...
11+
12+
# Repro path: create a devbox, fire 200 uploads with 50 in flight at once.
13+
./examples/stress_upload_file.py --concurrency 50 --total 200
14+
15+
# Reuse an existing devbox instead of creating one:
16+
./examples/stress_upload_file.py --devbox-id dbx_abc... --total 200
17+
18+
# Tune file size and SDK retry behavior:
19+
./examples/stress_upload_file.py --file-size-kb 256 --max-retries 0
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import os
25+
import sys
26+
import time
27+
import asyncio
28+
import argparse
29+
import tempfile
30+
import statistics
31+
from pathlib import Path
32+
from dataclasses import dataclass
33+
34+
import httpx
35+
36+
from runloop_api_client import AsyncRunloop
37+
from runloop_api_client._exceptions import APIStatusError
38+
39+
40+
@dataclass
41+
class UploadResult:
42+
index: int
43+
started_at: float
44+
finished_at: float
45+
status: str # "ok", "408", "other_error"
46+
detail: str = ""
47+
48+
@property
49+
def duration_ms(self) -> float:
50+
return (self.finished_at - self.started_at) * 1000.0
51+
52+
53+
def parse_args() -> argparse.Namespace:
54+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
55+
p.add_argument("--devbox-id", default=os.environ.get("RUNLOOP_DEVBOX_ID"),
56+
help="Reuse an existing devbox (default: create a new one)")
57+
p.add_argument("--base-url", default=os.environ.get("RUNLOOP_BASE_URL"),
58+
help="Override Runloop API base URL")
59+
p.add_argument("--concurrency", type=int, default=50,
60+
help="Number of upload_file calls in flight at once (default: 50)")
61+
p.add_argument("--total", type=int, default=200,
62+
help="Total number of uploads to perform (default: 200)")
63+
p.add_argument("--file-size-kb", type=int, default=64,
64+
help="Size of each uploaded file in KB (default: 64)")
65+
p.add_argument("--max-retries", type=int, default=None,
66+
help="Override SDK max_retries (default: SDK default of 5)")
67+
p.add_argument("--max-connections", type=int, default=None,
68+
help="Override SDK max_connections via connection_limits (default: SDK default of 100)")
69+
p.add_argument("--max-keepalive", type=int, default=None,
70+
help="Override max_keepalive_connections (only meaningful with --max-connections)")
71+
p.add_argument("--shutdown", action="store_true",
72+
help="Shut down the devbox after the run (only if we created it)")
73+
p.add_argument("--blueprint-name", default=None,
74+
help="Blueprint to launch from when creating a devbox")
75+
return p.parse_args()
76+
77+
78+
async def ensure_devbox(client: AsyncRunloop, args: argparse.Namespace) -> tuple[str, bool]:
79+
"""Return (devbox_id, created_by_us)."""
80+
if args.devbox_id:
81+
print(f"[setup] reusing devbox {args.devbox_id}")
82+
return args.devbox_id, False
83+
84+
print("[setup] creating a new devbox for the stress run...")
85+
kwargs: dict[str, object] = {}
86+
if args.blueprint_name:
87+
kwargs["blueprint_name"] = args.blueprint_name
88+
devbox = await client.devboxes.create_and_await_running(**kwargs) # type: ignore[arg-type]
89+
print(f"[setup] devbox {devbox.id} is running")
90+
return devbox.id, True
91+
92+
93+
async def one_upload(
94+
client: AsyncRunloop,
95+
devbox_id: str,
96+
src_path: Path,
97+
index: int,
98+
sem: asyncio.Semaphore,
99+
) -> UploadResult:
100+
async with sem:
101+
started = time.monotonic()
102+
try:
103+
await client.devboxes.upload_file(
104+
devbox_id,
105+
path=f"/tmp/stress_{index}.bin",
106+
file=src_path,
107+
)
108+
return UploadResult(index=index, started_at=started, finished_at=time.monotonic(), status="ok")
109+
except APIStatusError as exc:
110+
if exc.status_code == 408:
111+
status = "408"
112+
else:
113+
status = f"http_{exc.status_code}"
114+
return UploadResult(
115+
index=index,
116+
started_at=started,
117+
finished_at=time.monotonic(),
118+
status=status,
119+
detail=str(exc)[:200],
120+
)
121+
except (httpx.HTTPError, asyncio.TimeoutError) as exc:
122+
return UploadResult(
123+
index=index,
124+
started_at=started,
125+
finished_at=time.monotonic(),
126+
status=f"transport_{type(exc).__name__}",
127+
detail=str(exc)[:200],
128+
)
129+
130+
131+
def summarize(results: list[UploadResult], wall_seconds: float) -> int:
132+
by_status: dict[str, list[UploadResult]] = {}
133+
for r in results:
134+
by_status.setdefault(r.status, []).append(r)
135+
136+
print()
137+
print("=" * 72)
138+
print(f"Total uploads: {len(results)}")
139+
print(f"Wall time: {wall_seconds:.1f}s")
140+
print(f"Effective throughput:{len(results) / wall_seconds:.2f} uploads/s")
141+
print()
142+
print("Outcome breakdown:")
143+
for status in sorted(by_status):
144+
bucket = by_status[status]
145+
durations = sorted(r.duration_ms for r in bucket)
146+
p50 = statistics.median(durations)
147+
p95 = durations[int(0.95 * (len(durations) - 1))]
148+
p99 = durations[int(0.99 * (len(durations) - 1))]
149+
print(f" {status:>20} count={len(bucket):>5} p50={p50:>8.0f}ms p95={p95:>8.0f}ms p99={p99:>8.0f}ms")
150+
151+
sample_408 = by_status.get("408", [])[:3]
152+
if sample_408:
153+
print()
154+
print("Sample 408 errors:")
155+
for r in sample_408:
156+
print(f" #{r.index} duration={r.duration_ms:.0f}ms detail={r.detail}")
157+
158+
success = len(by_status.get("ok", []))
159+
return 0 if success == len(results) else 1
160+
161+
162+
async def run() -> int:
163+
args = parse_args()
164+
165+
if "RUNLOOP_API_KEY" not in os.environ:
166+
print("error: RUNLOOP_API_KEY is required", file=sys.stderr)
167+
return 2
168+
169+
client_kwargs: dict[str, object] = {}
170+
if args.base_url:
171+
client_kwargs["base_url"] = args.base_url
172+
if args.max_retries is not None:
173+
client_kwargs["max_retries"] = args.max_retries
174+
if args.max_connections is not None:
175+
limits_kwargs: dict[str, int] = {"max_connections": args.max_connections}
176+
if args.max_keepalive is not None:
177+
limits_kwargs["max_keepalive_connections"] = args.max_keepalive
178+
client_kwargs["connection_limits"] = httpx.Limits(**limits_kwargs)
179+
180+
payload = os.urandom(args.file_size_kb * 1024)
181+
with tempfile.NamedTemporaryFile(prefix="rl-stress-", suffix=".bin", delete=False) as f:
182+
f.write(payload)
183+
src_path = Path(f.name)
184+
print(f"[setup] payload: {src_path} ({len(payload)} bytes)")
185+
186+
try:
187+
async with AsyncRunloop(**client_kwargs) as client: # type: ignore[arg-type]
188+
devbox_id, created = await ensure_devbox(client, args)
189+
190+
print(
191+
f"[run] firing {args.total} upload_file calls "
192+
f"(concurrency={args.concurrency}) to {devbox_id}"
193+
)
194+
sem = asyncio.Semaphore(args.concurrency)
195+
t0 = time.monotonic()
196+
results = await asyncio.gather(
197+
*(one_upload(client, devbox_id, src_path, i, sem) for i in range(args.total))
198+
)
199+
wall = time.monotonic() - t0
200+
201+
exit_code = summarize(results, wall)
202+
203+
if created and args.shutdown:
204+
print(f"[teardown] shutting down devbox {devbox_id}")
205+
await client.devboxes.shutdown(devbox_id)
206+
finally:
207+
try:
208+
src_path.unlink()
209+
except OSError:
210+
pass
211+
212+
return exit_code
213+
214+
215+
if __name__ == "__main__":
216+
sys.exit(asyncio.run(run()))

src/runloop_api_client/_base_client.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,12 @@ def _should_retry(self, response: httpx.Response) -> bool:
870870

871871
# Retry on request timeouts.
872872
if response.status_code == 408:
873+
# upload_file 408s mean the server gave up waiting for the request body.
874+
# Retrying just queues another large request behind the same exhausted
875+
# connection pool and amplifies the stall.
876+
if response.request.url.path.endswith("/upload_file"):
877+
log.debug("Not retrying upload_file 408 (request-body stall)")
878+
return False
873879
log.debug("Retrying due to status code %i", response.status_code)
874880
return True
875881

@@ -931,6 +937,7 @@ class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]):
931937
_client: httpx.Client
932938
_default_stream_cls: type[Stream[Any]] | None = None
933939
_uses_shared_pool: bool
940+
_connection_limits: httpx.Limits | None
934941
_closed: bool
935942

936943
def __init__(
@@ -945,6 +952,7 @@ def __init__(
945952
custom_query: Mapping[str, object] | None = None,
946953
_strict_response_validation: bool,
947954
shared_http_pool: bool = True,
955+
connection_limits: httpx.Limits | None = None,
948956
) -> None:
949957
if not is_given(timeout):
950958
# if the user passed in a custom http client with a non-default
@@ -976,10 +984,20 @@ def __init__(
976984
)
977985

978986
self._closed = False
987+
self._connection_limits = connection_limits
979988

980989
if http_client is not None:
981990
self._client = http_client
982991
self._uses_shared_pool = False
992+
elif connection_limits is not None:
993+
# Custom limits get a private pool — the shared pool is process-global
994+
# so per-client limits can't be respected through it.
995+
self._client = SyncHttpxClientWrapper(
996+
base_url=base_url,
997+
timeout=cast(Timeout, timeout),
998+
transport=httpx.HTTPTransport(limits=connection_limits, http2=True),
999+
)
1000+
self._uses_shared_pool = False
9831001
elif shared_http_pool:
9841002
global _shared_sync_transport
9851003
with _pool_lock:
@@ -1563,6 +1581,7 @@ class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]):
15631581
_client: httpx.AsyncClient
15641582
_default_stream_cls: type[AsyncStream[Any]] | None = None
15651583
_uses_shared_pool: bool
1584+
_connection_limits: httpx.Limits | None
15661585
_closed: bool
15671586

15681587
def __init__(
@@ -1577,6 +1596,7 @@ def __init__(
15771596
custom_headers: Mapping[str, str] | None = None,
15781597
custom_query: Mapping[str, object] | None = None,
15791598
shared_http_pool: bool = True,
1599+
connection_limits: httpx.Limits | None = None,
15801600
) -> None:
15811601
if not is_given(timeout):
15821602
# if the user passed in a custom http client with a non-default
@@ -1608,10 +1628,20 @@ def __init__(
16081628
)
16091629

16101630
self._closed = False
1631+
self._connection_limits = connection_limits
16111632

16121633
if http_client is not None:
16131634
self._client = http_client
16141635
self._uses_shared_pool = False
1636+
elif connection_limits is not None:
1637+
# Custom limits get a private pool — the shared pool is process-global
1638+
# so per-client limits can't be respected through it.
1639+
self._client = AsyncHttpxClientWrapper(
1640+
base_url=base_url,
1641+
timeout=cast(Timeout, timeout),
1642+
transport=httpx.AsyncHTTPTransport(limits=connection_limits, http2=True),
1643+
)
1644+
self._uses_shared_pool = False
16151645
elif shared_http_pool:
16161646
try:
16171647
loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()

0 commit comments

Comments
 (0)