Round-Robin Broke My Agent A round-robin load balancer across three Azure OpenAI resources caused a 400 error on the Responses API because the API is stateful and requires requests to hit the same resource that created the items, with one failed call carrying a successful payload. The bug, which also degrades prompt caching hit rates, was identified by grouping gateway telemetry by operation, showing 64% of operations landing in one bucket. The first sign was a trace that made no sense. One inbound request to the gateway, three outgoing calls to three different Azure OpenAI resources. Two returned 400. The third returned 200. The client got its answer, 19.6 seconds later, and nobody filed a bug — the system was working , just expensively. What made it strange was the body attached to one of those 400s. It wasn’t an error. It was a complete, successful response object: "status": "completed", a created at, a completed at, "error": null. A failed call carrying a successful payload. That contradiction took a day to unwind, and the answer turned out to be a design assumption baked into almost every LLM gateway tutorial on the internet: that an LLM endpoint is a stateless HTTP endpoint, and you can therefore round-robin it. For chat completions, that’s true. For the API you are probably migrating to right now, it isn’t. The instinct behind an LLM load balancer is sound. A single Azure OpenAI resource has a per-deployment quota — tokens per minute, requests per minute. You have three resources. Put a gateway in front, round-robin across them, and you have tripled your ceiling. Every reference architecture shows this, and for the Chat Completions API it works perfectly, because a chat completion is a pure function: you send the entire conversation, you get an answer, the server keeps nothing. The Responses API is not a pure function. It is a stateful API, and the state lives on the resource that served the call. With the default store=true, every item the model produces is persisted server-side and handed back to you with an id: fc … for a function call, rs … for a reasoning item, msg … for a message, resp … for the response itself. Agent frameworks then replay those items as the next turn's input — that is how the model sees its own previous tool calls. The OpenAI Agents SDK does it on every turn of every run. Some code does it explicitly: the next turn carries the previous turn's items — ids and allfollow up = result.to input list + {"role": "user", "content": nudge} await Runner.run agent, follow up, max turns=8 Send those ids to a resource that didn’t mint them, and you get this — the actual response body, worth committing to memory: 400 — “The requested item was created under a different Azure OpenAI resource. Use the same resource that created the item to access it.” There it is. A round-robin pool guarantees that a two-resource-out-of-three miss happens on every turn after the first. The first turn has no ids, so it lands anywhere. Every turn after it is pinned to a resource the load balancer has already forgotten about. A 400 that succeeds when you retry it on a different host is not a transient error. It is a routing bug wearing a client error’s clothes. The useful generalization is not “the Responses API is special.” It’s that every LLM provider now ships a mix of stateless and resource-scoped endpoints, and the resource-scoped ones share a property: the API hands you an opaque id and expects you to hand it back. That last row is the one people miss, because nothing fails. Prompt caching discounts the repeated prefix of a request — the system prompt, the tool schemas, the retrieved context — and that cache lives on the resource that saw the prefix. Round-robin across N resources and a warm prefix has roughly a 1/N chance of landing where it's warm. Your correctness is fine; your cache hit rate quietly drops to a third and your bill doesn't. A load balancer built to save money on quota can cost you more than it saves. This class of bug has a shape you can query for, and the shape is what convinced me before I had a reproduction. Group your gateway telemetry by operation and count attempts: A transient fault — throttling, a bad node, a cold start — produces a binomial spread across those buckets. What I had was 64% of operations landing in exactly one bucket: N−1 failures, then a success, with the failures distributed evenly across all three backends. That is not randomness. That is “only one specific host can serve this request, and the balancer is finding it by exhaustion.” If you can express that query, you can detect this in ten minutes: // per-operation attempt shape — the tell is a spike at N-1 fails, 1 success dependencies| where timestamp ago 2d and target has "openai"| summarize fails=countif resultCode=="400" , oks=countif resultCode=="200" by operation Id| summarize operations=count by fails, oks| order by operations desc Remember that 400 carrying a successful payload? Every one of the 302 failures in my first sample had a body that began "status": "completed", and every body was truncated at exactly 8192 characters. The gateway was capturing the response of the attempt that eventually succeeded and attributing it to each attempt in the retry group. Two facts fell out of that: the diagnostic body limit was hiding the actual error, and a 2.5-second call cannot contain the seven seconds of generation its own log claimed. The fix for that is not a better query. It’s reproducing the call outside the gateway — hit each backend directly, with the same payload, and see what it really says. That took fifteen minutes and ended the speculation. The routing bug wasted two calls per turn. The retry policy in front of it was prepared to waste thirty. Here is what was actually deployed: