Skip to content

[serve] Add max_request_retries to bound router retry loops - #64399

Open
HrushiYadav wants to merge 6 commits into
ray-project:masterfrom
HrushiYadav:fix/serve-router-retry-limit
Open

HrushiYadav wants to merge 6 commits into
ray-project:masterfrom
HrushiYadav:fix/serve-router-retry-limit

Conversation

@HrushiYadav

Copy link
Copy Markdown
Contributor

Description

The router's while True retry loop in route_and_send_request and _pick_and_reserve_replica retries indefinitely when replicas reject requests. max_queued_requests is only checked once at the entry gate, not during retries. Under sustained load with large payloads (~50MB), requests pile up in the retry loop causing OOM -- num_queued_requests can reach 119 when max_queued_requests=20.

This PR adds max_request_retries to RequestRouterConfig:

  • Default -1 (unlimited) preserves backwards compatibility
  • When set to a non-negative integer, the router raises BackPressureError (503) after exhausting retries
  • Both route_and_send_request (replica rejection retry) and _pick_and_reserve_replica (capacity queue retry) are bounded
  • Configurable via RequestRouterConfig(max_request_retries=N) or env var RAY_SERVE_ROUTER_MAX_REQUEST_RETRIES

Related issues

Fixes #61017

Additional information

Files changed:

  • constants.py -- new RAY_SERVE_ROUTER_MAX_REQUEST_RETRIES env var (follows existing backoff env var pattern)
  • config.py -- new max_request_retries field on RequestRouterConfig with validator, __eq__, __hash__
  • router.py -- _check_retry_limit() helper, retry counter in both loops
  • test_backpressure.py -- config validation test + integration test with max_request_retries=3

Semantics:

  • max_request_retries=-1: unlimited (default, no behavior change)
  • max_request_retries=0: no retries (fail on first rejection)
  • max_request_retries=N: retry up to N times, then 503

@HrushiYadav
HrushiYadav requested a review from a team as a code owner June 28, 2026 11:19

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces a configurable limit on the maximum number of times a request can be retried by the router before being dropped with a BackPressureError. It adds max_request_retries to RequestRouterConfig and implements retry tracking and limit checking in AsyncioRouter. The review feedback suggests avoiding accessing the private _deployment_config attribute of RouterMetricsManager inside _check_retry_limit by instead storing and updating _max_queued_requests directly on AsyncioRouter.

Comment thread python/ray/serve/_private/router.py
Comment thread python/ray/serve/_private/router.py
Comment thread python/ray/serve/_private/router.py
@ray-gardener ray-gardener Bot added serve Ray Serve Related Issue community-contribution Contributed by the community labels Jun 28, 2026

@abrarsheikh abrarsheikh 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.

looks good. left some nits. please update the docs. Also, CI is failing.

Comment thread python/ray/serve/_private/router.py
Comment thread python/ray/serve/tests/test_backpressure.py Outdated
Comment thread python/ray/serve/tests/test_backpressure.py Outdated
Comment thread python/ray/serve/tests/test_backpressure.py Outdated
@HrushiYadav

Copy link
Copy Markdown
Contributor Author

Thanks for the review @abrarsheikh! Addressed all feedback:

  • Moved test_max_request_retries_config_validation to tests/unit/test_config.py
  • Moved inline imports to top of test_backpressure.py
  • Added request_router_config / max_request_retries to deployment configuration docs
  • The _max_queued_requests refactor was already done in the previous push

Comment thread python/ray/serve/config.py
@abrarsheikh

Copy link
Copy Markdown
Contributor

please resolve comments that are addressed already, include once from bot

@HrushiYadav

Copy link
Copy Markdown
Contributor Author

Addressed proto serialization issue: added optional int32 max_request_retries to RequestRouterConfig in serve.proto, plus round-trip tests. All review threads resolved.

@HrushiYadav
HrushiYadav force-pushed the fix/serve-router-retry-limit branch from 51ccde4 to c9445e0 Compare June 29, 2026 19:35
@HrushiYadav

Copy link
Copy Markdown
Contributor Author

@abrarsheikh All review threads are resolved (yours + bot). The Buildkite failure is in serve: enormous tests [g19_s7] (Windows) and serve: HAProxy tests -- both unrelated to the max_request_retries changes. Could you re-trigger CI or approve when you get a chance?

@abrarsheikh

Copy link
Copy Markdown
Contributor

The latest CI failure seems related to your changes. Please investigate

@HrushiYadav
HrushiYadav requested a review from a team as a code owner July 2, 2026 13:54
@HrushiYadav

Copy link
Copy Markdown
Contributor Author

You're right, thanks for pushing back. I dug in and the failure was my integration test, not flakiness.

The test used a single always-busy replica (max_ongoing_requests=1, blocked on a signal) to try to exhaust max_request_retries. But that scenario never reaches the retry counter: when every replica is at capacity, the request router's internal _choose_replicas_with_backoff loop backs off and blocks until a replica frees up, so _pick_and_reserve_replica never iterates. The request just stayed queued and hit the timeout instead of raising BackPressureError.

I replaced it with a deterministic unit test in test_router.py that drives the actual path this PR bounds (the one causing the OOM in #61017): a replica that repeatedly rejects reservations (_reject_reservation=True on both the initial and retry pick). With max_request_retries set, the router raises BackPressureError once the budget is exhausted. It follows the existing test_choose_replica_retries_when_reservation_rejected harness, so it's fast and not timing-dependent.

Pushed in 2164206.

@HrushiYadav
HrushiYadav force-pushed the fix/serve-router-retry-limit branch from 2164206 to 4dd41a2 Compare July 2, 2026 15:18
@HrushiYadav

Copy link
Copy Markdown
Contributor Author

@abrarsheikh Addressed the failing tests. Ready for another look whenever you have time, thanks!

@HrushiYadav

Copy link
Copy Markdown
Contributor Author

@abrarsheikh gentle nudge on this one whenever you get a chance, it's green and ready to go. no rush, thanks!

@github-actions

Copy link
Copy Markdown

This pull request has been automatically marked as stale because it has not had
any activity for 14 days. It will be closed in another 14 days if no further activity occurs.
Thank you for your contributions.

You can always ask for help on our discussion forum or Ray's public slack channel.

If you'd like to keep this open, just leave any comment, and the stale label will be removed.

@github-actions github-actions Bot added the stale The issue is stale. It will be closed within 7 days unless there are further conversation label Jul 25, 2026
@HrushiYadav

Copy link
Copy Markdown
Contributor Author

Still green and ready, not stale from my side. This bounds the router retry loop with max_request_retries, and the earlier test feedback from @abrarsheikh is addressed. @abrarsheikh @eicherseiji would either of you have a moment to take a look before the stale bot closes it? Happy to rebase if needed.

@abrarsheikh abrarsheikh added the go add ONLY when ready to merge, run all tests label Jul 27, 2026

@abrarsheikh abrarsheikh 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.

please add a e2e test

@HrushiYadav

Copy link
Copy Markdown
Contributor Author

Thanks. I dug into how a reservation rejection actually gets triggered so the e2e exercises the retry bound rather than the queue-full or capacity-backoff paths.

The catch: a rejection only happens in the optimistic-cache race window, and the cache self-corrects to the true queue length right after each rejection (on_new_queue_len_info in _pick_and_reserve_replica). So a single request accrues at most one rejection before it falls into the backoff/block path. That means real routing can only deterministically trip max_request_retries=0 (first rejection gives num_retries=1), and even producing that one rejection depends on concurrent routing tasks racing the semaphore, so it is timing sensitive.

For thresholds >= 1 I don't see a way to drive repeated rejections through real routing without a fake replica, which is why the deterministic coverage sits in the unit tests (FakeRunningReplica rejecting on reserve).

I can add a best-effort e2e that saturates a single replica (max_ongoing_requests=1, no max_queued_requests) with a SignalActor and fires a burst of concurrent requests, asserting at least one BackPressureError from the retry limit at max_request_retries=0. Happy to add it, just flagging it will be inherently timing dependent. Or if you had a specific e2e scenario in mind that avoids the race, point me at it and I will build it. What would you prefer?

@abrarsheikh

Copy link
Copy Markdown
Contributor

with max ongoing = 1 and unlimited queue request and max_request_retries=1 + using a signal actor to block requests.

Send 2 requests; 1st will be blocked by the signal actor and the 2nd request will sit in the queue, incurring a backoff. The second request will deterministically fail with a backpressure error, right?

Comment thread src/ray/protobuf/serve.proto
Comment thread python/ray/serve/_private/router.py
Comment thread python/ray/serve/_private/router.py
@github-actions github-actions Bot added unstale A PR that has been marked unstale. It will not get marked stale again if this label is on it. and removed stale The issue is stale. It will be closed within 7 days unless there are further conversation labels Jul 28, 2026
@HrushiYadav

Copy link
Copy Markdown
Contributor Author

Pushed changes addressing the review:

  • Added an e2e test (test_max_request_retries_bounds_retries in test_backpressure.py) using your recipe: a single replica at max_ongoing_requests=1, unlimited queue, max_request_retries=1, a signal actor blocking the first request; the second request deterministically fails with BackPressureError.
  • max_request_retries now only counts replica-capacity rejections. Transient ActorDiedError/ActorUnavailableError retries no longer consume the budget, per your preference on the actor-failures thread.
  • BackPressureError now reports retry-budget exhaustion (num_retries, max_request_retries) instead of the queue-limit fields, so the 503 body reflects the real cause.

Also resolved the bot threads that were already addressed. Ready for another look, and happy to re-trigger premerge whenever.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit a7237ba. Configure here.

Comment thread python/ray/serve/_private/router.py Outdated
@HrushiYadav
HrushiYadav force-pushed the fix/serve-router-retry-limit branch from a7237ba to d67fe68 Compare July 28, 2026 06:47
@HrushiYadav

Copy link
Copy Markdown
Contributor Author

@abrarsheikh I reverted my last push after digging into the new Bugbot finding ("Retry bound skips saturated replicas"), which turns out to be correct.

Tracing it: _choose_replica_for_request does replica = await pending_request.future, and that future is only fulfilled by a routing task once a replica actually has capacity. When the sole replica is saturated (the recipe from your earlier comment), the routing task just keeps backing off and never fulfills the future, so _route_and_send_request_once never reaches the capacity-rejection branch. num_retries never increments, BackPressureError is never raised, the request parks indefinitely, and the e2e test hangs. So the retry bound as written does not fire in the saturated case, which is the OOM scenario this PR is meant to bound.

This also runs into the "only capacity rejections count" preference: in the saturated case there is no capacity rejection to count, the request is stuck waiting inside the router backoff.

How would you like to bound it? Two options I see:

  1. Bound the wait in the choose-replica path itself (a timeout or a max-attempts cap on the backoff loop) and have that count toward max_request_retries.
  2. Keep capacity-rejection counting, but add a separate bound on time spent parked with no available replica.

Happy to implement whichever you prefer, then add an e2e test that actually terminates.

@HrushiYadav
HrushiYadav force-pushed the fix/serve-router-retry-limit branch from d67fe68 to d7c7363 Compare August 10, 2026 10:53
@HrushiYadav

Copy link
Copy Markdown
Contributor Author

Rebased on latest master, merge conflicts resolved. All 6 commits cherry-picked cleanly. CI should re-trigger now. @abrarsheikh

@HrushiYadav
HrushiYadav force-pushed the fix/serve-router-retry-limit branch from d7c7363 to d67fe68 Compare August 19, 2026 06:49
@HrushiYadav

Copy link
Copy Markdown
Contributor Author

Branch is up to date with master. @abrarsheikh — want to pick this back up? Happy to address any remaining feedback.

@HrushiYadav

Copy link
Copy Markdown
Contributor Author

Pushed a new commit (84ff199) that fixes the root cause and makes your recipe work.

Root cause: max_request_retries was only checked in _pick_and_reserve_replica (after reserve_slot returns accepted=False). When all replicas are saturated, the routing task backs off inside _fulfill_pending_requests / _choose_replicas_with_backoff and never reaches reserve_slot, so the retry counter never fired.

Fix:

  • Added _max_request_retries to RequestRouter base class, propagated from AsyncioRouter via update_max_request_retries() on both lazy init and config update.
  • In _fulfill_pending_requests, after each failed backoff attempt, check backoff_index > _max_request_retries and fail pending_request.future with BackPressureError.
  • In _choose_replica_for_request, catch BackPressureError from the future and clean up indices before re-raising.

Integration test uses your exact recipe: num_replicas=1, max_ongoing_requests=1, max_queued_requests=-1, max_request_retries=1, signal blocks request 1 → request 2 exhausts the backoff budget and raises BackPressureError deterministically.

The existing unit test (test_choose_replica_bounds_retries_on_repeated_rejection) still covers the reserve_slot rejection path. Happy to add a dedicated unit test for the backoff path if you'd like it.

@HrushiYadav
HrushiYadav force-pushed the fix/serve-router-retry-limit branch from d67fe68 to 9d91f26 Compare September 26, 2026 18:46
@HrushiYadav
HrushiYadav requested a review from a team as a code owner September 26, 2026 18:46
@HrushiYadav

HrushiYadav commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor Author

@abrarsheikh Rebased against master. Force-push: d67fe68f → 9d91f26d7f.

Only conflict was doc/source/serve/configure-serve-deployment.md — kept upstream's improved max_queued_requests wording (mentions backpressure_config) and added the request_router_config bullet for max_request_retries. All other commits applied cleanly via auto-merge.

9 files, +130/-1. CI should re-trigger now.

Signed-off-by: Hrushikesh Yadav <yadavhrushikesh65@gmail.com>
- Add self._max_queued_requests field initialized in __init__
- Update it in update_deployment_config alongside other config fields
- Use it in _check_retry_limit instead of accessing private
  _metrics_manager._deployment_config attribute

Signed-off-by: Hrushikesh Yadav <yadavhrushikesh65@gmail.com>
- Move test_max_request_retries_config_validation to unit tests dir
- Move inline imports to top of test_backpressure.py
- Add request_router_config and max_request_retries to deployment docs

Signed-off-by: Hrushikesh Yadav <yadavhrushikesh65@gmail.com>
- Add optional int32 max_request_retries field to RequestRouterConfig proto
- Using optional so unset (older controller) falls back to Pydantic default (-1)
- Add proto round-trip tests for explicit values and absent field

Signed-off-by: Hrushikesh Yadav <yadavhrushikesh65@gmail.com>
… unit test

The integration test in test_backpressure.py used a single always-busy
replica (max_ongoing_requests=1, blocked on a signal) to try to exhaust
max_request_retries. That scenario never surfaces to the retry counter:
when all replicas are at capacity, the request router's internal
_choose_replicas_with_backoff loop backs off and blocks until a replica
frees up, so _pick_and_reserve_replica never iterates and the request just
queues. The request timed out instead of raising BackPressureError.

Replace it with a unit test in test_router.py that drives the actual code
path ray-project#61017 fixes: a replica that repeatedly rejects reservations
(_reject_reservation=True on both the initial and retry pick). With
max_request_retries set, the router now raises BackPressureError once the
retry budget is exhausted. The test is deterministic and fast, following
the existing test_choose_replica_retries_when_reservation_rejected harness.

Signed-off-by: Hrushikesh Yadav <yadavhrushikesh65@gmail.com>
…s JSON

The new max_request_retries field on RequestRouterConfig (default -1) now
appears in the serialized deployment config returned by
get_serve_instance_details. Both test_controller.py and test_direct_ingress.py
hardcode the expected JSON and were missing the new key, so their
`assert details_dict == expected_dict` failed. Add "max_request_retries": -1
to the expected request_router_config block in both.

Signed-off-by: Hrushikesh Yadav <yadavhrushikesh65@gmail.com>
@HrushiYadav
HrushiYadav force-pushed the fix/serve-router-retry-limit branch from 9d91f26 to 86a1e9a Compare September 27, 2026 11:00

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution Contributed by the community go add ONLY when ready to merge, run all tests serve Ray Serve Related Issue unstale A PR that has been marked unstale. It will not get marked stale again if this label is on it.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Serve] Allow disabling router retry

2 participants