Skip to content

[serve] Add request_routing_timeout_s to bound replica assignment - #66512

Open
codechrl wants to merge 2 commits into
ray-project:masterfrom
codechrl:serve-request-routing-timeout-v2
Open

codechrl wants to merge 2 commits into
ray-project:masterfrom
codechrl:serve-request-routing-timeout-v2

Conversation

@codechrl

Copy link
Copy Markdown

Description

_choose_replica_for_request awaits the pending request's future with no bound. A request waits with no error while every replica is at capacity. It also waits while the deployment has no replicas, for example when a scaled-to-zero deployment can't schedule a new replica. HTTPOptions.request_timeout_s applies to the HTTP proxy only and also counts processing time. A DeploymentHandle caller can bound assignment only per call, by wrapping _to_object_ref() in asyncio.wait_for and cancelling the response on timeout. _to_object_ref() returns on assignment since #65243.

This adds request_routing_timeout_s to RequestRouterConfig. It follows the same path as the backoff fields: serve.proto, AsyncioRouter, then RequestRouter. When set, a request that isn't assigned a replica within the timeout raises TimeoutError. The default None keeps the current behavior. The unset path still awaits the future directly.

  • On timeout, both pending deques drop done entries from their front. The lazy cleanup in _fulfill_pending_requests never runs while there are no replicas, so timed-out requests would otherwise pile up with their args.
  • __eq__ and __hash__ include the field. broadcast_deployment_config_if_changed compares configs with ==, so a timeout-only change would otherwise never reach the routers.
  • The proto field is optional, so an unset value doesn't come back as 0.0.
  • A retry after a replica rejects the request starts a new timeout.
  • The experimental CapacityQueueRouter overrides _choose_replica_for_request, so the timeout doesn't apply to it.
  • Documented in performance.md next to the router backoff settings.

Related issues

Closes #40995

Additional information

Not a duplicate: no open or closed PR references #40995. #64399 (open) adds max_request_retries for #61017. It bounds the retry loops that run after a replica rejects a request. It doesn't bound the wait inside _choose_replica_for_request. Both PRs add field 9 to RequestRouterConfig in serve.proto, so whichever merges second needs to renumber.

Tests:

  • test_pow_2_request_router.py: timeout with no replicas, and with the only replica at capacity. Both fail with the request_router.py hunk reverted.
  • test_config.py: proto round trip for None and 1.5, and equality. The None case fails with a non-optional proto field. The equality test fails without the __eq__ change.
  • test_router.py: TestAsyncioRouterBackoffConfig also checks that the field reaches the request router. It fails with the router.py hunk reverted.

Ran with this branch's python/ray/serve on top of a Ray 2.54.1 wheel, Python 3.11:

pytest python/ray/serve/tests/unit/test_pow_2_request_router.py python/ray/serve/tests/unit/test_config.py
# 226 passed
pytest python/ray/serve/tests/unit/test_router.py -k TestAsyncioRouterBackoffConfig
# 1 passed

The rest of test_router.py fails the same way on master and on this branch in that setup, because master's Serve needs a matching core build.

Disclaimer: this PR was prepared with the assistance of an AI agent (Claude Code). All code and test changes were reviewed by the author before submission.

_choose_replica_for_request awaits the pending request's future with no
bound. A request waits with no error while every replica is at
capacity. It also waits while the deployment has no replicas, for
example when a scaled-to-zero deployment can't schedule a new replica.
HTTPOptions.request_timeout_s applies to the HTTP proxy only and also
counts processing time.

Add request_routing_timeout_s to RequestRouterConfig. When set, a
request that isn't assigned a replica within the timeout raises
TimeoutError. The timeout does not cover the time the replica takes to
handle the request. The default of None keeps the current behavior.

RequestRouterConfig equality includes the new field, so a timeout-only
config change is broadcast to routers.

Closes ray-project#40995

Signed-off-by: codechrl <codechrl@gmail.com>

@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 new configuration option, request_routing_timeout_s, allowing users to set a timeout for assigning requests to replicas in Ray Serve. Key changes include updates to RequestRouterConfig, the protobuf definition, documentation, and the request routing logic to handle and raise a timeout error when the limit is exceeded. The review feedback highlights a potential memory leak in the queue cleanup logic due to head-of-line blocking and suggests filtering the queues in-place. Additionally, it recommends raising asyncio.TimeoutError instead of the built-in TimeoutError to ensure backward compatibility with Python versions prior to 3.11, along with corresponding updates to the unit tests.

Comment on lines +1428 to +1433
for queue in (
self._pending_requests_to_fulfill,
self._pending_requests_to_route,
):
while queue and queue[0].future.done():
queue.popleft()

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.

high

The current cleanup logic only removes done/timed-out requests from the front of the queues (while queue and queue[0].future.done(): queue.popleft()). If there is a pending request at the front of the queue that has not timed out (e.g., because it has no timeout set), any timed-out requests behind it will remain in the queue indefinitely. This head-of-line blocking leads to a memory leak of timed-out requests and their associated arguments/metadata.

To prevent this memory leak, we should filter the queues in-place to remove all done/timed-out requests regardless of their position in the queue.

Suggested change
for queue in (
self._pending_requests_to_fulfill,
self._pending_requests_to_route,
):
while queue and queue[0].future.done():
queue.popleft()
for queue in (
self._pending_requests_to_fulfill,
self._pending_requests_to_route,
):
active_requests = [r for r in queue if not r.future.done()]
queue.clear()
queue.extend(active_requests)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

With a timeout set, requests mostly expire in FIFO order, and each expiry pops the done prefix. 50 staggered timeouts with no replicas leave both deques empty. Entries can sit behind a pending head that has a later deadline: a retry (re-inserted by created_at with a fresh timeout), or a head from before the timeout was lowered. They stay only until that head resolves or expires. They're held indefinitely only if the head was enqueued before any timeout was set, and that head waits forever regardless. Cancelled requests already get this lazy cleanup today. Rebuilding both deques on every timeout would be O(n) per timeout under overload, so I kept the lazy pop.

Comment on lines +1435 to +1438
raise TimeoutError(
f"Failed to route request to a replica of {self._deployment_id} "
f"within {self.request_routing_timeout_s}s."
) from None

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.

medium

In Python versions prior to 3.11, asyncio.TimeoutError and the built-in TimeoutError are distinct exception classes (with different inheritance hierarchies). Since this is an asynchronous API and callers typically expect asyncio.TimeoutError when awaiting async operations, raising asyncio.TimeoutError is more idiomatic and ensures backward compatibility for callers catching asyncio.TimeoutError on Python 3.8 - 3.10.

Suggested change
raise TimeoutError(
f"Failed to route request to a replica of {self._deployment_id} "
f"within {self.request_routing_timeout_s}s."
) from None
raise asyncio.TimeoutError(
f"Failed to route request to a replica of {self._deployment_id} "
f"within {self.request_routing_timeout_s}s."
) from None

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Builtin TimeoutError is intentional. The HTTP/gRPC proxies map isinstance(exc, TimeoutError) to 408/DEADLINE_EXCEEDED (http_util.py, grpc_util.py), and handle.py re-raises asyncio.TimeoutError as the builtin for the same reason. On 3.10, asyncio.TimeoutError isn't a subclass of the builtin, so switching would turn the 408 into a 500.

loop = get_or_create_event_loop()

task = loop.create_task(s._choose_replica_for_request(fake_pending_request()))
with pytest.raises(TimeoutError, match="Failed to route request to a replica"):

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.

medium

Update the test to expect asyncio.TimeoutError to match the exception raised by the request router.

Suggested change
with pytest.raises(TimeoutError, match="Failed to route request to a replica"):
with pytest.raises(asyncio.TimeoutError, match="Failed to route request to a replica"):

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The router raises the builtin TimeoutError on purpose (see the thread on request_router.py), so the test stays as is.

s.update_replicas([r1])

task = loop.create_task(s._choose_replica_for_request(fake_pending_request()))
with pytest.raises(TimeoutError, match="Failed to route request to a replica"):

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.

medium

Update the test to expect asyncio.TimeoutError to match the exception raised by the request router.

Suggested change
with pytest.raises(TimeoutError, match="Failed to route request to a replica"):
with pytest.raises(asyncio.TimeoutError, match="Failed to route request to a replica"):

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Same as above: the builtin TimeoutError is intentional, so the test stays as is.

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

Stale Bugbot comment from a previous run.

raise TimeoutError(
f"Failed to route request to a replica of {self._deployment_id} "
f"within {self.request_routing_timeout_s}s."
) from None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Timeout path leaves pending request future

High Severity

The TimeoutError handler never cancels pending_request.future, unlike the CancelledError path. Queue cleanup only drops entries whose futures are already done, so a still-pending timed-out request stays in _pending_requests_to_fulfill and _pending_requests_to_route. A later routing task can still assign a replica to that abandoned request, and the request args remain queued. On Python 3.11+, asyncio.wait_for no longer directly cancels a bare Future; it cancels the calling task via asyncio.timeout, so this is not guaranteed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ecff197. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

wait_for already cancels the awaited future on timeout. 3.10/3.11 call fut.cancel(). On 3.12+ asyncio.timeout cancels the task, and Task.cancel() cancels its _fut_waiter, which is this future. Checked on 3.10-3.13: future.cancelled() is True after the timeout. Routing tasks skip done futures, so a later replica goes to a live request. test_request_routing_timeout_no_replicas relies on this when it asserts the route queue is empty.

"only, not the time the replica takes to handle the request. "
"Defaults to None, meaning a request waits indefinitely."
),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

None timeout breaks proto serialization

High Severity

DeploymentConfig.to_proto dumps request_routing_timeout_s as None by default and passes it into RequestRouterConfigProto. The same function already pops retry_after_s when it is None so an optional proto field is left unset. Without that, default configs can raise TypeError during proto construction, which breaks deployment config broadcast for every deployment that leaves the timeout unset.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ecff197. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The proto field is optional, and the protobuf constructor treats None as unset. HasField is False on protobuf 3.20.3 (Ray's floor, cpp and python) and on 6.31.1. It doesn't raise. test_request_routing_timeout_proto_round_trip[None] covers this path.

@ray-gardener ray-gardener Bot added serve Ray Serve Related Issue docs An issue or change related to documentation community-contribution Contributed by the community labels Sep 27, 2026

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

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 0d9909b. Configure here.

double max_backoff_s = 8;

// Maximum time (in seconds) a request waits to be assigned a replica.
optional double request_routing_timeout_s = 9;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Proto change needs fault-tolerance review

Low Severity

⚠️ This PR modifies one or more .proto files.
Please review the RPC fault-tolerance & idempotency standards guide here:
https://github.com/ray-project/ray/tree/master/doc/source/ray-core/internals/rpc-fault-tolerance.rst

This is required by the RPC Fault Tolerance Standards Guide rule because serve.proto is in the change set.

Fix in Cursor Fix in Web

Triggered by project rule: Bugbot Rules

Reviewed by Cursor Bugbot for commit 0d9909b. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No RPC is added or changed. The PR only adds an optional config field to the RequestRouterConfig message. An unset field reads back as None, and older readers ignore it.

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 docs An issue or change related to documentation serve Ray Serve Related Issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Serve] Add a timeout parameter for scheduling ray tasks to replicas

1 participant