Skip to content

[bug] Under concurrent load, streamed responses intermittently send 200 headers but zero body bytes, though the server logs the request as successfully handled #541

Description

@adamcureton1

Investigated and drafted with AI assistance (Claude), based on hands-on reproduction testing against a live deployment and a separately built copy of current main — see Reproduction below for the scripts used.

Environment

  • Switchyard commit: 819e462c (current main at time of testing; also reproduces on 053a61e2, an earlier commit on the same branch)
  • switchyard-server --version reports: 0.2.0 (Cargo.toml version string; not yet bumped past the last tag)
  • OS: Linux, x86_64 (Debian 13)
  • Rust: 1.96.1
  • Backend: an OpenAI-compatible local inference server (LM Studio), reached via format = "openai_chat"
  • Backend model: a local MoE model, ~35B params
  • Inbound protocol tested: Anthropic Messages (/v1/messages) and OpenAI Chat Completions (/v1/chat/completions) — both show the same behavior
  • Streaming: enabled ("stream": true)
  • Route type: passthrough

Description

Under concurrent request load, Switchyard intermittently sends complete HTTP response headers (200 OK, Transfer-Encoding: chunked, Content-Type: text/event-stream) for a streamed request, then writes zero response body bytes, leaving the connection open indefinitely. The client never receives any SSE events, not even message_start.

Critically, Switchyard's own access log records the request as fully and successfully handled (status=200, a small handling_duration_ms) for every affected request — including the ones whose clients never receive a single body byte. This appears in both switchyard_server::request: LLM request handled log lines.

This is not specific to Anthropic-format translation. It reproduces identically when both the client and the backend speak plain OpenAI Chat Completions (no cross-format translation involved at all), which localizes the bug to Switchyard's general concurrent streaming-response delivery path rather than anything protocol-translation-specific.

Concurrency is the trigger, not request size. A large (~78KB) request sent sequentially — one at a time, never overlapping with another in-flight request — never hung in any of our tests (32 sequential attempts across two commits, generous 100s timeout, content varied per attempt to defeat backend prompt caching). The same request fired as 8 concurrent, overlapping requests hung on 6 of 8 attempts, consistently, across three separate test configurations (Anthropic-in/OpenAI-backend on two commits, and OpenAI-in/OpenAI-backend on current main).

What works

Verified directly, isolated from this bug:

  • Direct backend (LM Studio) text generation — correct
  • Direct backend streaming — correct
  • Direct backend tool-calling (OpenAI-style tools/tool_calls) — correct
  • Switchyard non-streaming Anthropic translation — correct, full body received
  • Switchyard streaming Anthropic translation, single sequential request (any size tested, up to ~78KB) — correct, full body received
  • Switchyard streaming tool-call translation (Anthropic tool_use / input_json_delta) — correct
  • Switchyard streaming OpenAI-to-OpenAI passthrough, single sequential request — correct

The failure requires concurrent in-flight streamed requests to the same Switchyard instance.

Reproduction

Two equivalent reproductions are attached: a curl-only shell script (repro.sh) for a quick check, and a Python harness (repro_concurrent.py) that reports precise per-request timing (time to headers, time to first body byte, total bytes, hang/success classification). A repro_sequential.py control script is included to show the same payload does not hang when requests don't overlap.

Both use a synthetic fixture (generate_fixture.py) — repeated filler text sized to ~78KB, no proprietary prompt content. Payload shape is intentionally generic (a system block + a short user message); no tools are required to reproduce it, though we also confirmed the bug with tool definitions present.

# routes.toml — minimal config used for testing
schema_version = 1

[llm_clients.local]
format = "openai_chat"
base_url = "http://127.0.0.1:PORT/v1"   # your OpenAI-compatible backend

[targets.local_model]
id = "YOUR-LOCAL-MODEL-ID"
llm_client = "local"

[routes.local]
id = "local"
type = "passthrough"
target = "local_model"
switchyard-server --config routes.toml --host 0.0.0.0 --port 4000

# in another terminal:
./repro.sh localhost:4000 local

Expect roughly 60-80% of the 8 concurrent requests to receive 200 OK headers and then nothing — curl reports Operation timed out ... with 0 bytes received after the 100s --max-time. The rest complete normally, usually within the time the backend actually takes to generate a response for that request size.

repro_concurrent.py (Python, precise timing)

python3 generate_fixture.py repro.json
# edit repro.json's "model" field to match your route id
python3 repro_concurrent.py repro.json <switchyard-host-ip> <port> 8 /v1/messages

Observed result (one hung worker, from an actual run)

> POST /v1/messages HTTP/1.1
> Content-Length: 78248
...

< HTTP/1.1 200 OK
< content-type: text/event-stream
< cache-control: no-cache
< transfer-encoding: chunked
< date: ...

[connection stays open — zero body bytes — for the full 100s client timeout]

Switchyard's log for the same request, timestamped within ~200ms of the request arriving:

INFO switchyard_server::request: LLM request handled wire_format=anthropic_messages
  status=200 requested_model="local" selected_model="<backend-model-id>"
  streaming=true handling_duration_ms=152.8 error=""

Packet capture (on an earlier, single-request reproduction that first surfaced this) confirms the same shape at the wire level: the response's HTTP headers are sent as a single ~200-byte packet ending in the header-terminating blank line, and no further bytes follow on that connection.

Expected result

A valid SSE stream (message_start → content deltas → message_stop, or the OpenAI-format equivalent) should begin promptly and terminate normally, for every concurrently in-flight request — matching what happens when the same requests are sent sequentially.

Reproduction frequency

Measured across three independent 8-concurrent-request batches (two different Switchyard commits, two different inbound wire formats):

Anthropic-in / OpenAI-backend, commit 053a61e2: 6 hangs in 8 concurrent attempts
Anthropic-in / OpenAI-backend, commit 819e462c (main): 6 hangs in 8 concurrent attempts
OpenAI-in    / OpenAI-backend, commit 819e462c (main): 6 hangs in 8 concurrent attempts

Sequential (non-overlapping) requests, same payload, same commits: 0 hangs in 32 attempts (20 against main, 12 against 053a61e2).

This is a standalone, high-probability reproduction under concurrency — we did not push it to full determinism, and the exact concurrency level, timing, and backend response speed needed likely affect the hit rate.

Request size behavior

Larger (Claude-Code-scale, ~60-85KB) requests made the issue easy to notice in real-world use, but size alone does not appear to be the trigger — sequential requests of the same size never hung in our testing. We did not fully map the size sensitivity under concurrency (e.g., whether smaller concurrent requests also hang, just less often); we'd guess size matters insofar as it affects how long a request stays in flight (and therefore how likely it is to overlap with others), rather than being a hard threshold in its own right, but we're flagging this as a guess, not a measured result.

Additional isolation (protocol matrix)

Only rows we actually tested:

Inbound format Backend format Streaming Concurrency Result
Anthropic OpenAI Chat no 1 passes
Anthropic OpenAI Chat yes 1 (sequential) passes, every size tested up to ~78KB
Anthropic OpenAI Chat yes 8 concurrent 6/8 hang
OpenAI Chat OpenAI Chat yes 8 concurrent 6/8 hang

Possible area to investigate

We have not modified or debugged the source, so this is tentative, offered only to narrow where a maintainer might look first. The candidates we noted while reading (read-only) were:

  • crates/switchyard-server/src/sse.rsframe_stream, which wraps the translated event stream in Axum's Sse response type. This is a thin wrapper over a well-tested Axum primitive, so we consider it a less likely culprit on its own, but it's the last stage before bytes reach the client.
  • crates/libsy-llm-client/src/client.rs — around call_rewrite_model_raw / call_rewrite_model, specifically the http_response.bytes_stream()decode_stream(...) chain (~lines 428-450 in the commit we tested) that adapts the backend's request byte stream into the neutral IR chunk stream Switchyard re-encodes from. Since the bug reproduces identically for OpenAI-to-OpenAI (no format translation), we'd look at whatever is shared across all streaming responses regardless of format, rather than anything format-specific in switchyard-translation's codecs.

We did not find a way to distinguish, from outside the process, whether the backend response is fully consumed and then dropped, or never fully consumed in the first place — a maintainer with source-level tracing/instrumentation could resolve that quickly where we couldn't from outside.

We looked for related recent changes and found none: the 7 commits on main since 053a61e2 don't touch switchyard-server, switchyard-translation, or libsy-llm-client's streaming paths (the closest, an extraction of config/routing into a new switchyard-runner crate, only touches decision-endpoint types and token-counting).


*Reproduction files:

generate_fixture.py

repro_concurrent.py

repro_sequential.py

repro.sh

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions