[serve] Add max_request_retries to bound router retry loops - #64399
[serve] Add max_request_retries to bound router retry loops#64399HrushiYadav wants to merge 7 commits into
Conversation
There was a problem hiding this comment.
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.
abrarsheikh
left a comment
There was a problem hiding this comment.
looks good. left some nits. please update the docs. Also, CI is failing.
|
Thanks for the review @abrarsheikh! Addressed all feedback:
|
|
please resolve comments that are addressed already, include once from bot |
|
Addressed proto serialization issue: added |
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>
51ccde4 to
c9445e0
Compare
|
@abrarsheikh All review threads are resolved (yours + bot). The Buildkite failure is in |
|
The latest CI failure seems related to your changes. Please investigate |
|
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 ( I replaced it with a deterministic unit test in 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>
2164206 to
4dd41a2
Compare
…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>
|
@abrarsheikh Addressed the failing tests. Ready for another look whenever you have time, thanks! |
|
@abrarsheikh gentle nudge on this one whenever you get a chance, it's green and ready to go. no rush, thanks! |
|
This pull request has been automatically marked as stale because it has not had 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. |
|
Still green and ready, not stale from my side. This bounds the router retry loop with |
abrarsheikh
left a comment
There was a problem hiding this comment.
please add a e2e test
|
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 ( 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 ( |
|
with max ongoing = 1 and unlimited queue request and 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? |
|
Pushed changes addressing the review:
Also resolved the bot threads that were already addressed. Ready for another look, and happy to re-trigger premerge whenever. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit a7237ba. Configure here.
| # transient actor failures are retried without consuming the budget. | ||
| if capacity_rejected: | ||
| num_retries += 1 | ||
| self._check_retry_limit(num_retries) |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit a7237ba. Configure here.
a7237ba to
d67fe68
Compare
|
@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: 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:
Happy to implement whichever you prefer, then add an e2e test that actually terminates. |


Description
The router's
while Trueretry loop inroute_and_send_requestand_pick_and_reserve_replicaretries indefinitely when replicas reject requests.max_queued_requestsis 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_requestscan reach 119 whenmax_queued_requests=20.This PR adds
max_request_retriestoRequestRouterConfig:-1(unlimited) preserves backwards compatibilityBackPressureError(503) after exhausting retriesroute_and_send_request(replica rejection retry) and_pick_and_reserve_replica(capacity queue retry) are boundedRequestRouterConfig(max_request_retries=N)or env varRAY_SERVE_ROUTER_MAX_REQUEST_RETRIESRelated issues
Fixes #61017
Additional information
Files changed:
constants.py-- newRAY_SERVE_ROUTER_MAX_REQUEST_RETRIESenv var (follows existing backoff env var pattern)config.py-- newmax_request_retriesfield onRequestRouterConfigwith validator,__eq__,__hash__router.py--_check_retry_limit()helper, retry counter in both loopstest_backpressure.py-- config validation test + integration test withmax_request_retries=3Semantics:
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