Skip to content

fix(server): configurable HTTP timeouts + don't evict upstream on call-scoped cancellation - #968

Merged
github-actions[bot] merged 6 commits into
mainfrom
fix/965-http-timeouts-no-evict
Aug 9, 2026
Merged

fix(server): configurable HTTP timeouts + don't evict upstream on call-scoped cancellation#968
github-actions[bot] merged 6 commits into
mainfrom
fix/965-http-timeouts-no-evict

Conversation

@Dumbris

@Dumbris Dumbris commented Aug 9, 2026

Copy link
Copy Markdown
Member

Fixes #965.

Problem

Two independent failure modes, one visible outcome: a single slow tool call destroys its own response and takes the upstream offline for every client.

  1. Hardcoded 120s WriteTimeout on the proxy's own HTTP server (internal/server/server.go). A write deadline is a wall-clock cap on the entire response counted from header read, so any tool call slower than ~2 minutes had its response destroyed in transit — the upstream work completed, but the caller couldn't know. The same deadline silently killed long-lived SSE /events streams and streamable-HTTP GET streams. No config override existed.
  2. Call-path cancellations classified as connection failures. When a caller disconnects (or any deadline fires), the request context cancels the in-flight upstream call; the resulting context canceled matched isConnectionError's substrings, so managed.Client.CallTool called StateManager.SetError — flipping the whole upstream to Error, failing fast for every client, and burning reconnect budget. (fix(upstream): require N consecutive health-check timeouts before marking Error #470's consecutive-failure threshold only covers the health-check path.)

Fix

Configurable HTTP timeouts + per-route deadline exemption for streaming

Three new global keys, tri-state *Duration (unset = built-in default, "0s" = no deadline, positive = that deadline; validated {0} ∪ [1s, 24h]; restart-required; env overrides MCPPROXY_HTTP_READ/WRITE/IDLE_TIMEOUT):

Key Default Applied to
http_read_timeout 120s http.Server.ReadTimeout
http_write_timeout 120s http.Server.WriteTimeout
http_idle_timeout 180s http.Server.IdleTimeout

Defaults are unchanged — instead of relaxing them globally, the streaming routes opt out per-request: /mcp*, /v1/tool_code|tool-code, and /events are wrapped in a streamingNoDeadline middleware that clears the write deadline (and, for body-less GET/HEAD streams, the read deadline) via http.ResponseController. So:

  • Long tool calls and SSE streams work out of the box, no config needed.
  • REST/Web-UI/health keep the 120s slow-reader bound (matters for non-loopback deployments; ReadHeaderTimeout: 60s stays hardcoded as the slowloris guard, POST bodies keep their read deadline).
  • Operators can still tighten/relax the global deadlines for the non-streaming surface via the new keys.

Note: call_tool_timeout (default 2m) still caps tool execution — raising it is what enables >2m tool calls; it returns a proper MCP error rather than destroying the response mid-flight.

DetectConfigChanges compares the resolved values and reports these fields as restart-required. ReloadConfiguration (file-watcher path) now logs a warning when an external edit touches any restart-required field — previously silent (pre-existing gap for listen/TLS too).

A canceled call is not a dead server

managed.Client.CallTool now classifies errors most-specific-first:

  • Caller context ended (ctx.Err() != nil) → call-scoped by definition; log, never touch server state.
  • Ambiguous cancellation (wrapped context.Canceled/DeadlineExceeded or cancellation text, while the caller ctx is live) → don't evict on a guess: fire one gated async liveness probe (existing livenessProber seam, 5s budget). The probe follows the health loop's own policy: probe healthy → server untouched; probe fails transiently (timeout/overload/non-connection error) → left to the background health loop's 3-strike threshold; probe fails with hard transport evidence → mark Error. A connectionEpoch counter guards the detached probe against acting on a session that reconnected while it was in flight.
  • Hard transport evidence (connection refused/reset, broken pipe, dial i/o timeout…) → unchanged: immediate SetError.

isConnectionError itself is untouched — ListTools, the health loop, and reconnect still rely on its matching.

Behavior notes: on an ambiguous cancellation against a genuinely dead server, Error arrives after the probe (≤5s) or, if the ping only ever times out, from the health loop (~90s) — the deliberate trade for not evicting busy-but-alive servers. The probe error becomes LastError rather than the call error.

Not in scope

Per-upstream write timeout (requested as "ideal" in the issue): an inbound listener deadline is not an upstream property — one /mcp session dispatches to many upstreams. A per-upstream execution deadline would belong beside call_tool_timeout; with the streaming routes exempt from the write deadline the need mostly disappears.

Tests

  • internal/config/http_timeouts_test.go — resolvers (incl. explicit-0-stays-disabled), validation bounds, JSON round-trip, env overrides incl. malformed values.
  • internal/server/http_timeouts_test.go — resolution helper + real-net/http behavior test: a 300ms write deadline truncates a 600ms response on an unwrapped route; the streamingNoDeadline wrapper rescues the same response; a GET stream survives active read+write deadlines.
  • internal/upstream/managed/calltool_cancel_test.go — production-shaped error strings: caller-cancel keeps Ready; ambiguous cancel probes (healthy → stays Ready, dead → becomes Error); hard errors still evict immediately; probe is gated to one in flight; classifier table incl. British "cancelled" and real-world Post "…": context deadline exceeded shapes.
  • Real-behavior tests carry negative controls (middleware removed → tests fail).
  • Verified: go build both editions, go vet, golangci-lint v2 (.github/.golangci.yml) 0 issues, -race suites for config/upstream/runtime/server, ./scripts/test-api-e2e.sh 65/65, make swagger artifacts committed.

…l-scoped cancellation (GH #965)

Part 1: http_read_timeout / http_write_timeout / http_idle_timeout config
keys (tri-state *Duration; 0 = no deadline; restart-required; env
overridable). The write-timeout default changes from a hardcoded 120s to
0: a write deadline is a wall-clock cap on the whole response, so it
truncated any tool call slower than 2 minutes and silently killed SSE
/events streams. Slowloris protection stays via the 60s ReadHeaderTimeout.

Part 2: managed.Client.CallTool no longer treats call-scoped context
cancellation as proof of a dead server. Caller-ctx cancellation never
evicts; ambiguous wrapped cancellations trigger one gated async liveness
probe (probe failure = the hard evidence that does evict); genuine
transport errors (refused/reset/broken pipe) evict immediately as before.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 9, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: b540523
Status: ✅  Deploy successful!
Preview URL: https://d58c31c6.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-965-http-timeouts-no-evi.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Aug 9, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 91.12426% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/upstream/managed/client.go 81.48% 11 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/965-http-timeouts-no-evict

Available Artifacts

  • archive-darwin-amd64 (28 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (22 MB)
  • installer-dmg-darwin-arm64 (20 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 31298900430 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Dumbris added 5 commits August 9, 2026 09:02
…dening (GH #965)

- Restore the 120s write-timeout default; instead of disabling it globally,
  the streaming routes (/mcp*, /v1/tool_code*, /events) clear their
  per-request write deadline (and read deadline for body-less GET/HEAD)
  via http.ResponseController. Long tool calls and SSE work out of the
  box while REST/UI/health keep slow-reader protection.
- Ambiguous-cancellation probe now follows the health loop's policy:
  only hard transport evidence evicts; transient/non-connection ping
  failures defer to the background health loop's 3-strike threshold.
- connectionEpoch guards the detached probe against SetError-ing a
  session that reconnected while the probe was in flight.
- ReloadConfiguration warns when a file edit touches restart-required
  fields (pre-existing silent gap, now visible).
- Per-field swagger doc comments so all three http_*_timeout keys are
  documented in the OAS spec.
…ut fallback (GH #965)

- Connect now bumps connectionEpoch BEFORE exposing Ready, and both run
  under epochMu, paired with the probe's final check-and-SetError — a
  reconnect can no longer complete between the probe's staleness check
  and its verdict, and a new session is never visible with the old epoch.
- http_idle_timeout "0s" documented accurately: net/http falls back to
  ReadTimeout when IdleTimeout is 0, so idle is unbounded only when
  http_read_timeout is also 0 (resolver, struct comment, all three docs,
  OAS regenerated); stale hot-reload comment fixed; resolver-contract
  test case added for idle-zero-alone.
…ack wording everywhere (GH #965)

- Connect no longer holds epochMu across TransitionTo(Ready): the
  transition invokes its state-change callback synchronously, so the
  mutex now guards only the epoch bump (still ordered before Ready;
  correctness analysis in the comment). Lock invariant documented
  truthfully: SetError's callback is async, TransitionTo's is not.
- Every remaining blanket '0s = no timeout' statement (docs x3, config
  const + struct comments, env WARN message) now carries the
  IdleTimeout→ReadTimeout fallback caveat; OAS regenerated.
…(GH #965)

- Disconnect now bumps connectionEpoch under epochMu before resetting the
  state machine, closing the last stale-probe window: an in-flight
  ambiguous-call probe can no longer flip a freshly Disconnected client
  back to Error (which also burned a retry and emitted a bogus
  notification). Regression test simulates the teardown ordering.
- Last two blanket '0s disables the deadline' comments (loader env sink,
  config test header) now carry the IdleTimeout→ReadTimeout caveat.
…on test (GH #965)

The disconnect-race test now tears down through Client.Disconnect (nil
core-client guard added, same fallback contract as toolInvoker/
healthProbe) instead of simulating the ordering. Honest coverage note:
the sub-microsecond Reset-vs-verdict interleaving is unobservable from
outside precisely because epochMu serializes it; the epoch-guard
mutation is killed by the reconnect-path test, where the epoch is the
only discriminator.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approved (Model B): Paperclip review verdicts = ACCEPT and qa-gate green at this head SHA. Arming auto-merge; GitHub merges when all required checks pass.

@github-actions
github-actions Bot merged commit d8cb143 into main Aug 9, 2026
40 checks passed
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.

Hardcoded 120s HTTP WriteTimeout caps every tool call, and the resulting timeout evicts the upstream for all clients

2 participants