fix(middleware): bound the upstream singleflight call with the backend timeout - #850
Open
silverbackdan wants to merge 1 commit into
Open
Conversation
✅ Deploy Preview for teal-sprinkles-4c7f14 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
…d timeout An upstream call that never returns holds its singleflight entry forever: the key can't be used anymore until the process restarts and every request joining it stays parked in the WaitGroup even once the client got its 504. Use DoChan and select against the request context, which already carries the backend timeout, then forget the key when it's exceeded so the next request runs a new call. The deduplication is unchanged for the normal path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
silverbackdan
force-pushed
the
fix/upstream-singleflight-timeout
branch
from
August 14, 2026 07:36
3404cfe to
67ee7b1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #849.
Problem
Upstream()coalesces the upstream calls withsingleflight.Do, andDokeeps the entry in the group until the callback returns. An upstream that never returns therefore has two consequences:wg.Wait()afterwards.In the report the process had one owner blocked for 177 minutes:
and 1127 joiners accumulated behind it, the oldest also 177 minutes old:
The pod couldn't pass its readiness probe anymore on a URL it was otherwise serving in ~7ms.
Change
Upstream()now usesDoChanand selects the result againstrq.Context().Done(). The request context already carries the backend timeout (context/timeout.go), so the shared call gets the same bound as its callers, no new constant nor configuration key. When it's exceeded the key is forgotten, so the next request runs a new call instead of inheriting the dead one, and the caller returns instead of waiting forever.Two details:
DoChan, so the recover moved inside it. The panic is stored and rethrown in the caller goroutine, where the existingdeferhandleshttp.ErrAbortHandleras before. Otherwise singleflight rethrows it withgo panic(e), in a goroutine nobody can recover from.Upstream()can now return while the callback is still running, the background goroutine inServeHTTPdoesn't return the buffer to the pool when the request context is done: the abandoned call may still write into it.Deduplication is unchanged on the normal path: concurrent requests still share the call and still log
Reused response from concurrent request with the key.Revalidate()has the sameDopattern. I left it alone to keep this focused, happy to align it the same way if you'd like.Tests
TestStalledUpstreamDoesNotHoldTheSingleflightKeycovers it: an upstream that never returns, a second call for the same key that must return when the backend timeout is exceeded rather than wait for it, and a third one that must reach the upstream and get its body once the key has been forgotten. On master it fails withBUG: the request that joined the stalled upstream call waits for it indefinitely.go build ./...andgo test -race ./...pass, and the new test passes with-race -count=4. Plugins weren't built, only the root module.