Repository navigation
grpctunnel: explain every dispatch failure (phase, stream state, agent report) - #186
Conversation
…t report)
A failed dispatch now says what went wrong, not just who gave up. Prod's
2s /__axon/info probe failures and 60s upstream timeouts both logged as
caller_canceled with nothing to tell them apart.
- DispatchError gains Phase (acquire vs awaiting_response), the token's
stream state at failure (streams, busy, waiters, per-instance split, oldest
holding calls with path and age), and a FailureCause: streams_exhausted,
upstream_slow, agent_unresponsive, agent_at_capacity, ...
- CallCancel carries the agent's account: stage, cause, stage/total elapsed,
upstream host. The agent fills it in on every early end, including its
answer to a server-sent cancel.
- On a server-side timeout the call is held, stream busy, for up to
AGENT_REPORT_GRACE (1s) for that answer. This also stops the server
dispatching onto a stream the agent is still running the old call on.
- The response goes out at once with X-Axon-Dispatch-Failure/-Phase; the log
line waits for the agent and records the final cause, counted in a new
dispatch.failures{cause} metric. (The old tagged dispatch.errors helper
collided with the untagged counter and would have panicked if called.)
- Repro tests for both prod shapes in dispatch and adapters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| case !notifyAgent || !c.held: | ||
| // Either the agent's own side is over (its Cancel or End, or the | ||
| // stream died under it), or there is nothing to wait for. A repeat | ||
| // server-side cancel of a held call keeps waiting. | ||
| c.recvDone = true |
There was a problem hiding this comment.
i think if something fails mid read here there might be a case where the stream gets reused.
E.g. dispatch -> cancelCall -> recvDone = true
There was a problem hiding this comment.
Good catch, confirmed. A request-body failure cancels the call and holds it while the agent answers, and then Dispatch cancelled it a second time with notifyAgent=false. That second cancel treated "don't notify the agent" as "the agent is done", so it set recvDone and released the stream while the agent was still running the call. Fixed in 8ac580c. cancelCall never releases a held call now: only the agent's own final frame, the grace timer, a failed cancel send, or the stream closing ends the hold. There's a repro test in TestDispatch_RequestBodyFailureHoldsStreamUntilAgentReports.
There was a problem hiding this comment.
You found a bug!
Reward: 🏆
keithfz
left a comment
There was a problem hiding this comment.
one thing that might be worth double checking on but LGTM
…y the agent Review finding: when the caller's request body fails mid-read, the body pump cancels the call toward the agent and holds it, and Dispatch then cancels it again, without notifying, on seeing the error on errC. cancelCall took "don't notify the agent" to mean "the agent is done" and set recvDone, releasing the stream while the agent was still running the call, so the next dispatch could land on a busy stream. A held call is now released only by the agent's own terminal frame, the grace timer, a cancel that could not be sent, or the stream closing -- HandleStreamClose ends the hold explicitly. With no grace period nothing is ever held, so that path is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Context
Scoping: The goal of this is to send more context about why calls might fail from the agent back to the server. It's not a functional change for how the tunnel works, but requires fishing context gathering calls through the flows. Its bloated by a proto change and a bunch of new structs.
So, after some customer testing of
grpc-tunnelSo two PRs coming:
=> This one - now that we have our own stack we can start adding more context. Here we tell the server side WHY a call failed from the agents perspective so we can diagnose these classes of issues from the backend.
=> Upcoming - improve some stream management semantics for better load balancing.
Why
Production shows two dispatch failure shapes that the logs can't tell apart today. Both end as
kind: caller_canceled, with nothing about stream state and nothing from the agent:/__axon/infoprobe failures. A token's only stream on one relay pod was busy with another call, so the probe waited for a stream until the dispatcher's 2s client gave up (acquireWait ≈ elapsed ≈ 2s, no callId).This PR is the first of a series. It makes every failure explain itself so we can tell "the upstream was too slow" from "slow upstreams ate all our streams". Fixing the per-pod stream sizing that causes the starvation comes next.
What changes
Server:
DispatchErrorexplains itselfPhase:acquire(never got a stream) orawaiting_response(the call was with the agent).Streams: the token's state on this server at the moment of failure:Cause(dispatch.FailureCause):streams_exhausted,upstream_slow,upstream_error,agent_at_capacity,agent_unresponsive,route_rejected,agent_shutdown,tunnel_error,no_tunnel, …Agent: explains its own failures (
CallCancelfields 3–7, all optional, wire-compatible both ways)QUEUED_FOR_CAPACITY,ROUTING,AWAITING_UPSTREAM_HEADERS,STREAMING_BODY), a cause, time in that stage, total time, and the upstream host.agent/server/grpctunnel/call_progress.go.Server holds a cancelled call open for the agent's answer (
AGENT_REPORT_GRACE, default 1s; 0 disables)CallCancelarrives or the grace period runs out.CallDataduring the hold fails fast instead of blocking the stream's receive loop. A stream that dies during the hold ends it immediately.Surfacing the cause
X-Axon-Dispatch-Failure: <cause>andX-Axon-Dispatch-Phase. The 404 broker-signature response is left byte-identical.Dispatch failedline is written after the agent answers, so it records the final cause plusagentStage,agentCause,upstreamHost,holdersandtokenInstances.streams_exhausted,upstream_slow, …) is now logged at Warn instead of Info.dispatch.failures{tenant_id,integration,alias,cause}counter. The previous taggedmetrics.DispatchErrorhelper reused the untaggeddispatch.errorsname with different labels, so Prometheus panics when it's called. That's likely why it was never wired up.README.relay.mddocuments the causes.Test plan
server/dispatch/failure_diagnostics_test.go: reproduces both production shapes:server/dispatch/failure_cause_test.go: table tests for the cause mappingserver/adapters/dispatch_failure_cause_test.go: end-to-end log fields and response headers forstreams_exhausted,upstream_slow,agent_unresponsiveandagent_at_capacityagent/server/grpctunnel/call_report_test.go: the agent's stage and cause for a server cancel, the agent's own deadline, an upstream error, a capacity queue timeout, and a rejected routego test -race ./...inserver/and inagent/server/grpctunnelcause=streams_exhaustedwithholders, and the 60s failures should logcause=upstream_slowwithupstreamHost🤖 Generated with Claude Code