Skip to content

grpctunnel: explain every dispatch failure (phase, stream state, agent report) - #186

Merged
shawnburke merged 2 commits into
mainfrom
shawn/tunnel-dispatch-diagnostics
Nov 10, 2026
Merged

shawnburke merged 2 commits into
mainfrom
shawn/tunnel-dispatch-diagnostics

Conversation

@shawnburke

@shawnburke shawnburke commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

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-tunnel

  • Its working well
  • There are some issues with stream management and timeouts that need some iteration.

So 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:

  • 2s /__axon/info probe 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).
  • 60s timeouts on an upstream-facing relay. The call got a stream and reached the agent, and the agent was still waiting on the upstream for headers when the caller gave up. A few never got a stream at all, because slow calls held every stream on that pod.

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: DispatchError explains itself

  • Phase: acquire (never got a stream) or awaiting_response (the call was with the agent).
  • Streams: the token's state on this server at the moment of failure:
    • total streams, busy streams, and dispatches waiting for one
    • the split per agent instance
    • the oldest calls holding streams, with method, path (query string stripped) and age
  • 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 (CallCancel fields 3–7, all optional, wire-compatible both ways)

  • Every early end reports the call's stage (QUEUED_FOR_CAPACITY, ROUTING, AWAITING_UPSTREAM_HEADERS, STREAMING_BODY), a cause, time in that stage, total time, and the upstream host.
  • That includes the agent's answer to a cancel sent by the server.
  • Stage tracking lives in agent/server/grpctunnel/call_progress.go.

Server holds a cancelled call open for the agent's answer (AGENT_REPORT_GRACE, default 1s; 0 disables)

  • When the server gives up on a call the agent is still running, it keeps the call registered and its stream busy until the agent's CallCancel arrives or the grace period runs out.
  • Fixes an existing bug: today the stream is released at once. The server can then dispatch onto it while the agent is still running the old call, and the agent's reply is dropped as "frame for unknown call".
  • Late CallData during 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

  • Response: the response goes out immediately with X-Axon-Dispatch-Failure: <cause> and X-Axon-Dispatch-Phase. The 404 broker-signature response is left byte-identical.
  • Log: the Dispatch failed line is written after the agent answers, so it records the final cause plus agentStage, agentCause, upstreamHost, holders and tokenInstances.
  • Log level: a caller cancel caused by something on our side (streams_exhausted, upstream_slow, …) is now logged at Warn instead of Info.
  • Metric: new dispatch.failures{tenant_id,integration,alias,cause} counter. The previous tagged metrics.DispatchError helper reused the untagged dispatch.errors name with different labels, so Prometheus panics when it's called. That's likely why it was never wired up.
  • README.relay.md documents the causes.

Test plan

  • server/dispatch/failure_diagnostics_test.go: reproduces both production shapes:
    • a probe starved by a slow call (deadline and caller-cancel variants)
    • a slow upstream timing out after send
    • the hold-for-report cases: agent reports, agent silent, agent completes late, stream closes mid-hold, no grace
  • server/dispatch/failure_cause_test.go: table tests for the cause mapping
  • server/adapters/dispatch_failure_cause_test.go: end-to-end log fields and response headers for streams_exhausted, upstream_slow, agent_unresponsive and agent_at_capacity
  • agent/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 route
  • go test -race ./... in server/ and in agent/server/grpctunnel
  • After deploy: the 2s probe failures should log cause=streams_exhausted with holders, and the 60s failures should log cause=upstream_slow with upstreamHost

🤖 Generated with Claude Code

…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>
Comment thread server/dispatch/dispatcher.go Outdated
Comment on lines +866 to +870
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think if something fails mid read here there might be a case where the stream gets reused.

E.g. dispatch -> cancelCall -> recvDone = true

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You found a bug!
Reward: 🏆

keithfz
keithfz previously approved these changes Nov 9, 2026

@keithfz keithfz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@shawnburke
shawnburke merged commit 01b3dae into main Nov 10, 2026
30 checks passed
@shawnburke
shawnburke deleted the shawn/tunnel-dispatch-diagnostics branch November 10, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants