Skip to content

fix: evict dead tunnel routes so offline tunnels 404 instead of 502 - #5930

Open
vishalg0wda wants to merge 1 commit into
mainfrom
vishal/tunnel-dead-route-unpublish
Open

fix: evict dead tunnel routes so offline tunnels 404 instead of 502#5930
vishalg0wda wants to merge 1 commit into
mainfrom
vishal/tunnel-dead-route-unpublish

Conversation

@vishalg0wda

@vishalg0wda vishalg0wda commented Sep 1, 2026

Copy link
Copy Markdown
Member

AIS-662

Summary

  • proxy.Proxy gains a ForwardErrorRetryer hook, invoked once when the upstream forward fails with a transport error before any response headers arrive (mirrors UpstreamResponseRetryer, which only sees gateway-responded errors).
  • tunnelrouting.DeadDialRetryer implements it for tunnels: on a dead-peer dial failure it unpublishes the dead route, retries another candidate if one exists (the dial failed, so the request never left gram-server and replay is safe for any method), and otherwise surfaces the same CodeNotFound that route selection uses for tunnels with no live route.
  • Wired in tunnelManager.buildProxy, so every non-pinned tunnel path (serve endpoint, public tunnels, meta proxy, consent enumeration) gets it.

Motivation

#5888 made dead pinned sessions fail fast into a 404, but a stale route (a gateway pod that died without unpublishing) still 502s every non-pinned request for the rest of its 30s TTL, one 3-second dial timeout at a time. Retrying MCP clients rack up enough of those to page the platform 5xx monitors, and on custom domains the traffic is indistinguishable from Gram-origin errors at the ingress (the rewrite to /mcp/<slug> happens inside gram-server), so no monitor-side exclusion can catch it. Evicting the route on the first failed dial turns a dead tunnel into a customer-visible 404 instead of a platform 502 burst.


Summary by cubic

Evicts dead tunnel gateway routes on the first failed dial so offline tunnels return 404 instead of a burst of 502s. Fixes AIS-662.

Bug Fixes

  • Adds a ForwardErrorRetryer hook to proxy.Proxy for transport errors that occur before any response headers arrive.
  • DeadDialRetryer unpublishes the dead route and fails over to another candidate, or returns CodeNotFound when none remain.
  • Wired into every non-pinned tunnel path via buildProxy; replaying after a dial failure is safe because the request never left gram-server.

Written for commit 7b75e7f. Summary will update on new commits.

Review in cubic

@vishalg0wda
vishalg0wda requested a review from a team as a code owner September 1, 2026 14:01
@vishalg0wda vishalg0wda added the bug Something isn't working label Sep 1, 2026
@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 7b75e7f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@linear-code

linear-code Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

AIS-662

@cubic-dev-ai cubic-dev-ai Bot 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.

1 issue found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/internal/remotemcp/proxy/proxy.go">

<violation number="1" location="server/internal/remotemcp/proxy/proxy.go:1008">
P2: When the replacement gateway returns a retryable 502, `forwardRequestWithRetry` bypasses `UpstreamResponseRetryer`, so tunnel response errors are returned without eviction or failover. Route the replacement response through the existing response-retry handling, while preserving the intended retry limit.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread server/internal/remotemcp/proxy/proxy.go
Thegreatsura pushed a commit to Thegreatsura/gram that referenced this pull request Sep 3, 2026
…api#5935)

## Summary

Introduces a single-writer `routeReconciler`: one per-pod goroutine owns
every mutating
route-store call (publish, connection snapshots, unpublish, global
revoke). Handlers mark a
tunnel dirty and nudge; the reconciler reads live registry state at
write time, batch-refreshes
all live tunnels on one ticker (replacing the per-session refresher
goroutines), gates
publishes on `IsActive`, and prunes tracking for cleanly-removed
tunnels.

Because all writes are serialized through one goroutine, graceful
shutdown becomes the
reconciler's final act: on SIGTERM the registry stops admitting agent
connects (checked again
under the lock at registration), the reconciler performs one bounded
batched cleanup of every
route this pod may own, the forward server drains in-flight requests,
agent sessions close
concurrently, and `main` blocks until the sequence completes — all
within the existing 25s
budget. Publish-after-cleanup races are impossible by construction
rather than coordinated
away.

Tests are black-box at the gateway boundary (real WebSocket agent +
yamux sessions against an
in-memory store): post-drain store emptiness with no writes after drain
returns, 503 on
connect during drain, in-flight forwards completing through shutdown,
TTL refresh cadence,
revocation across multiple owners, and a concurrent-churn convergence
property under the race
detector.

## Motivation

The gateway exited without unpublishing its routes, so every rollout
left pod-IP routes in
Redis for up to the 30s TTL while the pod was already gone. gram-server
dialed the dead IP and
each affected tunneled request hung ~30s before surfacing a 502,
producing an error burst on
every deploy. Draining removes the stale-route window; agents re-home to
surviving pods in
~0.5–2s using their existing reconnect backoff. Complements speakeasy-api#5888 (fast
dial timeout) and
speakeasy-api#5930 (dead-route eviction on dial failure), which cover unplanned pod
death.

closes AIM-166
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant