Repository navigation
Conversation
The per-request timer was never cancelled, so it kept the waiter and the copied request context alive for the full timeout after each request. Cancel it in a finally block. The 504 on timeout is unchanged.
This branch has not been deployed
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.
What does this PR address?
TimeoutMiddlewareschedules acall_latertimer for every request but never cancels it. If the request finishes in time, the timer just sits in the event loop untiltraffic.timeoutruns out (60s by default) and then fires on a future that is already done.That would be harmless, except the pending
TimerHandlekeeps a few things alive the whole time: thewaiterfuture, and thecontextvars.Contextthatcall_latercopies when it schedules the callback. That context includes whatever the outer middlewares put there, e.g. the OpenTelemetry span. So every worker ends up holding on to every request from the last 60 seconds.We ran into this while chasing long full-GC pauses on an internal service. At ~180 req/s per worker it adds up to roughly 240k extra live objects per worker (~22 per request). They survive the young-generation collections and land in gen 2, so every full collection has to walk all of them.
Related to #5642, which is a different problem in the same middleware. This PR doesn't touch that.
Repro
On
mainthis printslive TimerHandle=1000. With this change it prints1: that's the last request's timer, already cancelled, and the loop drops it on its next iteration. (Python 3.12, default asyncio loop.)Numbers from our service
Sync API, 6 workers, 1000 req/s in total. Two 10-minute runs per variant.
Fix
Keep the handle and cancel it in a
finally. That covers normal completion, errors from the app, and cancellation. If the timeout has already fired,cancel()is a no-op, so the 504 path doesn't change.Tests
Added
tests/unit/_internal/server/test_traffic.py:test_timeout_middleware_cancels_timer: fails onmain, passes with the fix.test_timeout_middleware_returns_504_on_timeout: checks the timeout response still works.Before submitting:
pre-commit run -ascript has passed? (All hooks pass. buf-format and buf-lint were skipped, same as pre-commit.ci; this PR doesn't touch the gRPC protos.)