Skip to content

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

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

[serve] Add max_request_retries to bound router retry loops#64399
HrushiYadav wants to merge 7 commits into
ray-project:masterfrom
HrushiYadav:fix/serve-router-retry-limit

Conversation

@HrushiYadav

Copy link
Copy Markdown

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
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
Author

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

The router's retry loop retries indefinitely when replicas reject
requests. Under sustained load with large payloads, requests pile up
in the retry loop causing OOM (num_queued_requests far exceeds
max_queued_requests).

Add max_request_retries to RequestRouterConfig (default -1 = unlimited
for backwards compatibility). When the limit is exceeded, raise
BackPressureError (503) to shed load. Both route_and_send_request and
_pick_and_reserve_replica retry loops are bounded.

Fixes ray-project#61017

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

… 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>
@HrushiYadav
HrushiYadav force-pushed the fix/serve-router-retry-limit branch from 2164206 to 4dd41a2 Compare July 2, 2026 15:18
…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

Copy link
Copy Markdown
Author

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

@HrushiYadav

Copy link
Copy Markdown
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
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
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
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
# transient actor failures are retried without consuming the budget.
if capacity_rejected:
num_retries += 1
self._check_retry_limit(num_retries)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Retry bound skips saturated replicas

High Severity

max_request_retries only increments after a replica is chosen and then capacity-rejects. When replicas are already saturated, _choose_replica_for_request stays inside its internal backoff and never returns, so the outer counters never move and BackPressureError is never raised. That leaves requests parked indefinitely (the OOM case this PR aims to bound), and test_max_request_retries_bounds_retries hangs waiting for that error.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a7237ba. Configure here.

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

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