-
Notifications
You must be signed in to change notification settings - Fork 421
[tinker] Forward samples with aiohttp instead of httpx #2161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: avi/stack-1-access-log
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,7 +7,8 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||||||||||||||||||||||||||
| from datetime import datetime, timezone | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| import httpx | ||||||||||||||||||||||||||||||||||||||||||||||
| import aiohttp | ||||||||||||||||||||||||||||||||||||||||||||||
| import orjson | ||||||||||||||||||||||||||||||||||||||||||||||
| from sqlmodel.ext.asyncio.session import AsyncSession | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| from skyrl.backends.renderer import render_model_input | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -19,6 +20,13 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| from skyrl.utils.log import logger | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| class TransientInferenceError(RuntimeError): | ||||||||||||||||||||||||||||||||||||||||||||||
| """A 5xx from vllm-router/vLLM: the request was rejected, not executed, so it is safe to retry.""" | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| _ROUTER_CONNECT_TIMEOUT_SECONDS = 60.0 | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| class SkyRLTrainInferenceForwardingClient: | ||||||||||||||||||||||||||||||||||||||||||||||
| """Forwards EXTERNAL sample requests to the SkyRL-Train-managed vLLM.""" | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -36,27 +44,48 @@ def __init__( | |||||||||||||||||||||||||||||||||||||||||||||
| self.external_future_store = external_future_store | ||||||||||||||||||||||||||||||||||||||||||||||
| self._cached_proxy_url: str | None = None | ||||||||||||||||||||||||||||||||||||||||||||||
| self._cache_lock = asyncio.Lock() | ||||||||||||||||||||||||||||||||||||||||||||||
| # Backpressure layered: httpx pool -> vllm-router -> vLLM max_num_seqs. | ||||||||||||||||||||||||||||||||||||||||||||||
| # Default `forwarding_inference_max_connections=None` is unlimited; | ||||||||||||||||||||||||||||||||||||||||||||||
| # the only cost is file descriptors (raise `ulimit -n` accordingly). | ||||||||||||||||||||||||||||||||||||||||||||||
| max_conn = engine_config.forwarding_inference_max_connections | ||||||||||||||||||||||||||||||||||||||||||||||
| max_keepalive = max(max_conn // 4, 32) if max_conn is not None else None | ||||||||||||||||||||||||||||||||||||||||||||||
| self._http_client: httpx.AsyncClient = httpx.AsyncClient( | ||||||||||||||||||||||||||||||||||||||||||||||
| timeout=httpx.Timeout( | ||||||||||||||||||||||||||||||||||||||||||||||
| connect=10.0, | ||||||||||||||||||||||||||||||||||||||||||||||
| read=engine_config.forwarding_inference_timeout_sec, | ||||||||||||||||||||||||||||||||||||||||||||||
| write=300.0, | ||||||||||||||||||||||||||||||||||||||||||||||
| pool=300.0, | ||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||
| limits=httpx.Limits( | ||||||||||||||||||||||||||||||||||||||||||||||
| max_connections=max_conn, | ||||||||||||||||||||||||||||||||||||||||||||||
| max_keepalive_connections=max_keepalive, | ||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| # Created on first use so it binds to the serving event loop. | ||||||||||||||||||||||||||||||||||||||||||||||
| self._session: aiohttp.ClientSession | None = None | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def _get_session(self) -> aiohttp.ClientSession: | ||||||||||||||||||||||||||||||||||||||||||||||
| """Return the shared aiohttp session, creating it on first use. | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| Backpressure is layered: connector limit -> vllm-router -> vLLM | ||||||||||||||||||||||||||||||||||||||||||||||
| max_num_seqs. Default `forwarding_inference_max_connections=None` is | ||||||||||||||||||||||||||||||||||||||||||||||
| unlimited; the only cost is file descriptors (raise `ulimit -n` | ||||||||||||||||||||||||||||||||||||||||||||||
| accordingly). Requests beyond the limit wait in the connector's FIFO | ||||||||||||||||||||||||||||||||||||||||||||||
| queue with no deadline, so a backlog of many thousands of samples | ||||||||||||||||||||||||||||||||||||||||||||||
| drains at the engine's pace instead of failing. | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| aiohttp rather than httpx: httpcore's pool rescans every connection | ||||||||||||||||||||||||||||||||||||||||||||||
| for every request, so its per-request CPU grows with the number of | ||||||||||||||||||||||||||||||||||||||||||||||
| in-flight samples (~28ms each at 512 in flight); aiohttp stays flat. | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
| if self._session is None or self._session.closed: | ||||||||||||||||||||||||||||||||||||||||||||||
| max_conn = self.engine_config.forwarding_inference_max_connections | ||||||||||||||||||||||||||||||||||||||||||||||
| # keepalive_timeout must stay under the router's idle timeout so a | ||||||||||||||||||||||||||||||||||||||||||||||
| # pooled connection is never reused after the server closed it. | ||||||||||||||||||||||||||||||||||||||||||||||
| # Happy Eyeballs is off: a burst of connect timeouts cancels its | ||||||||||||||||||||||||||||||||||||||||||||||
| # sock_connect calls mid-flight, and under uvloop the closed sockets' | ||||||||||||||||||||||||||||||||||||||||||||||
| # descriptors get reused before the loop forgets them ("File | ||||||||||||||||||||||||||||||||||||||||||||||
| # descriptor N is used by transport"), failing unrelated forwards. | ||||||||||||||||||||||||||||||||||||||||||||||
| connector = aiohttp.TCPConnector(limit=max_conn or 0, keepalive_timeout=2, happy_eyeballs_delay=None) | ||||||||||||||||||||||||||||||||||||||||||||||
| self._session = aiohttp.ClientSession( | ||||||||||||||||||||||||||||||||||||||||||||||
| connector=connector, | ||||||||||||||||||||||||||||||||||||||||||||||
| timeout=aiohttp.ClientTimeout( | ||||||||||||||||||||||||||||||||||||||||||||||
| total=None, | ||||||||||||||||||||||||||||||||||||||||||||||
| # A saturated router can take tens of seconds to accept; | ||||||||||||||||||||||||||||||||||||||||||||||
| # that is queueing, not failure. | ||||||||||||||||||||||||||||||||||||||||||||||
| sock_connect=_ROUTER_CONNECT_TIMEOUT_SECONDS, | ||||||||||||||||||||||||||||||||||||||||||||||
| sock_read=self.engine_config.forwarding_inference_timeout_sec, | ||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| return self._session | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| async def aclose(self) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||
| """Close the persistent httpx client. Called from api.py lifespan shutdown.""" | ||||||||||||||||||||||||||||||||||||||||||||||
| await self._http_client.aclose() | ||||||||||||||||||||||||||||||||||||||||||||||
| """Close the shared aiohttp session. Called from api.py lifespan shutdown.""" | ||||||||||||||||||||||||||||||||||||||||||||||
| if self._session is not None and not self._session.closed: | ||||||||||||||||||||||||||||||||||||||||||||||
| await self._session.close() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| async def _read_proxy_url_from_db(self) -> str | None: | ||||||||||||||||||||||||||||||||||||||||||||||
| async with AsyncSession(self.db_engine) as session: | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -119,30 +148,31 @@ async def call_and_store_result( | |||||||||||||||||||||||||||||||||||||||||||||
| await session.commit() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| async def _forward_with_retry(self, sample_req, model_id: str, *, base_model: str | None) -> types.SampleOutput: | ||||||||||||||||||||||||||||||||||||||||||||||
| # Retry only failures that occur before a request can reach vLLM. Read | ||||||||||||||||||||||||||||||||||||||||||||||
| # and write failures are ambiguous: vLLM may still be executing the | ||||||||||||||||||||||||||||||||||||||||||||||
| # Retry only failures where the request demonstrably did not execute: | ||||||||||||||||||||||||||||||||||||||||||||||
| # connect-phase errors and 5xx rejections from the router. Read and | ||||||||||||||||||||||||||||||||||||||||||||||
| # write failures are ambiguous: vLLM may still be executing the | ||||||||||||||||||||||||||||||||||||||||||||||
| # request, so retrying would duplicate generation load. | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| proxy_url = await self._resolve_proxy_url() | ||||||||||||||||||||||||||||||||||||||||||||||
| return await self._forward(proxy_url, sample_req, model_id, base_model=base_model) | ||||||||||||||||||||||||||||||||||||||||||||||
| except (httpx.ConnectError, httpx.ConnectTimeout) as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| except (aiohttp.ClientConnectorError, aiohttp.ConnectionTimeoutError, TransientInferenceError) as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||
| "Connection error talking to %s (%s: %s) — refreshing proxy URL and retrying once", | ||||||||||||||||||||||||||||||||||||||||||||||
| "Transient error talking to %s (%s: %s) — refreshing proxy URL and retrying once", | ||||||||||||||||||||||||||||||||||||||||||||||
| self._cached_proxy_url, | ||||||||||||||||||||||||||||||||||||||||||||||
| type(e).__name__, | ||||||||||||||||||||||||||||||||||||||||||||||
| e, | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| proxy_url = await self._resolve_proxy_url(force_refresh=True) | ||||||||||||||||||||||||||||||||||||||||||||||
| return await self._forward(proxy_url, sample_req, model_id, base_model=base_model) | ||||||||||||||||||||||||||||||||||||||||||||||
| except httpx.ReadTimeout as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| except aiohttp.SocketTimeoutError as e: | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+159
to
+168
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Catch the custom
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||
| # Not retried (see above). Long-context requests routinely exceed the | ||||||||||||||||||||||||||||||||||||||||||||||
| # default read deadline, so tell the caller how to raise it. The | ||||||||||||||||||||||||||||||||||||||||||||||
| # message is stored in the FutureDB ErrorResponse and shown to clients. | ||||||||||||||||||||||||||||||||||||||||||||||
| timeout_sec = self.engine_config.forwarding_inference_timeout_sec | ||||||||||||||||||||||||||||||||||||||||||||||
| raise RuntimeError( | ||||||||||||||||||||||||||||||||||||||||||||||
| f"Inference request to {self._cached_proxy_url} timed out after {timeout_sec:g}s waiting for " | ||||||||||||||||||||||||||||||||||||||||||||||
| "a response (httpx.ReadTimeout). The request was not retried because vLLM may still be " | ||||||||||||||||||||||||||||||||||||||||||||||
| "a response (read timeout). The request was not retried because vLLM may still be " | ||||||||||||||||||||||||||||||||||||||||||||||
| "executing it. If requests are expected to take this long (long prompts, large max_tokens, " | ||||||||||||||||||||||||||||||||||||||||||||||
| "or queueing behind other requests), increase the deadline with " | ||||||||||||||||||||||||||||||||||||||||||||||
| "`--forwarding-inference-timeout-sec` (EngineConfig.forwarding_inference_timeout_sec) or " | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -195,17 +225,22 @@ async def _forward( | |||||||||||||||||||||||||||||||||||||||||||||
| headers["X-Session-ID"] = session_id | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| url = f"{proxy_url}/v1/completions" | ||||||||||||||||||||||||||||||||||||||||||||||
| response = await self._http_client.post(url, json=payload, headers=headers) | ||||||||||||||||||||||||||||||||||||||||||||||
| if response.status_code >= 400: | ||||||||||||||||||||||||||||||||||||||||||||||
| raise RuntimeError(f"vLLM /v1/completions returned {response.status_code}: {response.text}") | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| result = response.json() | ||||||||||||||||||||||||||||||||||||||||||||||
| except ValueError as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| # vllm-router can return HTML on transient errors even with 2xx status. | ||||||||||||||||||||||||||||||||||||||||||||||
| raise RuntimeError( | ||||||||||||||||||||||||||||||||||||||||||||||
| f"vLLM /v1/completions returned non-JSON ({response.status_code}, " | ||||||||||||||||||||||||||||||||||||||||||||||
| f"content-type={response.headers.get('content-type')!r}): {response.text[:512]}" | ||||||||||||||||||||||||||||||||||||||||||||||
| ) from e | ||||||||||||||||||||||||||||||||||||||||||||||
| async with self._get_session().post(url, json=payload, headers=headers) as response: | ||||||||||||||||||||||||||||||||||||||||||||||
| body = await response.read() | ||||||||||||||||||||||||||||||||||||||||||||||
| if response.status >= 500: | ||||||||||||||||||||||||||||||||||||||||||||||
| raise TransientInferenceError( | ||||||||||||||||||||||||||||||||||||||||||||||
| f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}" | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+230
to
+233
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||||||||||||||||||
| if response.status >= 400: | ||||||||||||||||||||||||||||||||||||||||||||||
| raise RuntimeError(f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}") | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| result = orjson.loads(body) | ||||||||||||||||||||||||||||||||||||||||||||||
| except orjson.JSONDecodeError as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| # vllm-router can return HTML on transient errors even with 2xx status. | ||||||||||||||||||||||||||||||||||||||||||||||
| raise RuntimeError( | ||||||||||||||||||||||||||||||||||||||||||||||
| f"vLLM /v1/completions returned non-JSON ({response.status}, " | ||||||||||||||||||||||||||||||||||||||||||||||
| f"content-type={response.headers.get('content-type')!r}): {body[:512].decode(errors='replace')}" | ||||||||||||||||||||||||||||||||||||||||||||||
| ) from e | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+228
to
+243
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In try:
async with self._get_session().post(url, json=payload, headers=headers) as response:
try:
body = await response.read()
except asyncio.TimeoutError as e:
raise ReadTimeoutError("Read timeout") from e
if response.status >= 500:
raise TransientInferenceError(
f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}"
)
if response.status >= 400:
raise RuntimeError(f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}")
try:
result = orjson.loads(body)
except orjson.JSONDecodeError as e:
# vllm-router can return HTML on transient errors even with 2xx status.
raise RuntimeError(
f"vLLM /v1/completions returned non-JSON ({response.status}, "
f"content-type={response.headers.get('content-type')!r}): {body[:512].decode(errors='replace')}"
) from e
except asyncio.TimeoutError as e:
raise ConnectionTimeoutError("Connection timeout") from e |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| prompt_logprobs = None | ||||||||||||||||||||||||||||||||||||||||||||||
| topk = None | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,12 +1,14 @@ | ||||||||||||||||||||||||||||||||
| import argparse | ||||||||||||||||||||||||||||||||
| from unittest.mock import AsyncMock, call, patch | ||||||||||||||||||||||||||||||||
| from types import SimpleNamespace | ||||||||||||||||||||||||||||||||
| from unittest.mock import AsyncMock, call | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| import httpx | ||||||||||||||||||||||||||||||||
| import aiohttp | ||||||||||||||||||||||||||||||||
| import pytest | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| from skyrl.tinker.config import EngineConfig, add_model | ||||||||||||||||||||||||||||||||
| from skyrl.tinker.extra.skyrl_train_inference_forwarding import ( | ||||||||||||||||||||||||||||||||
| SkyRLTrainInferenceForwardingClient, | ||||||||||||||||||||||||||||||||
| TransientInferenceError, | ||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||
|
Comment on lines
9
to
12
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Import the custom
Suggested change
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
@@ -21,20 +23,37 @@ def test_forwarding_timeout_reads_environment(monkeypatch) -> None: | |||||||||||||||||||||||||||||||
| assert config.forwarding_inference_timeout_sec == 1800.0 | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| def test_forwarding_client_uses_configured_timeout() -> None: | ||||||||||||||||||||||||||||||||
| @pytest.mark.asyncio | ||||||||||||||||||||||||||||||||
| async def test_forwarding_client_uses_configured_timeout_and_connection_limit() -> None: | ||||||||||||||||||||||||||||||||
| config = EngineConfig( | ||||||||||||||||||||||||||||||||
| base_model="test-model", | ||||||||||||||||||||||||||||||||
| forwarding_inference_timeout_sec=1800.0, | ||||||||||||||||||||||||||||||||
| forwarding_inference_max_connections=64, | ||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||
| client = SkyRLTrainInferenceForwardingClient(config, db_engine=None) | ||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||
| session = client._get_session() | ||||||||||||||||||||||||||||||||
| assert session.timeout.sock_connect == 60.0 | ||||||||||||||||||||||||||||||||
| assert session.timeout.sock_read == 1800.0 | ||||||||||||||||||||||||||||||||
| # No overall deadline: a request may wait in the connector queue for | ||||||||||||||||||||||||||||||||
| # as long as the engine takes to get to it. | ||||||||||||||||||||||||||||||||
| assert session.timeout.total is None | ||||||||||||||||||||||||||||||||
| assert session.connector.limit == 64 | ||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||
| await client.aclose() | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @pytest.mark.asyncio | ||||||||||||||||||||||||||||||||
| async def test_forwarding_client_default_connection_limit_is_unlimited() -> None: | ||||||||||||||||||||||||||||||||
| client = SkyRLTrainInferenceForwardingClient(EngineConfig(base_model="test-model"), db_engine=None) | ||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||
| assert client._get_session().connector.limit == 0 | ||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||
| await client.aclose() | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| with patch("skyrl.tinker.extra.skyrl_train_inference_forwarding.httpx.AsyncClient") as async_client: | ||||||||||||||||||||||||||||||||
| SkyRLTrainInferenceForwardingClient(config, db_engine=None) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| timeout = async_client.call_args.kwargs["timeout"] | ||||||||||||||||||||||||||||||||
| assert timeout.connect == 10.0 | ||||||||||||||||||||||||||||||||
| assert timeout.read == 1800.0 | ||||||||||||||||||||||||||||||||
| assert timeout.write == 300.0 | ||||||||||||||||||||||||||||||||
| assert timeout.pool == 300.0 | ||||||||||||||||||||||||||||||||
| def _connect_error(message: str) -> aiohttp.ClientConnectorError: | ||||||||||||||||||||||||||||||||
| return aiohttp.ClientConnectorError(SimpleNamespace(ssl=None, host="inference", port=8000), OSError(message)) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @pytest.mark.asyncio | ||||||||||||||||||||||||||||||||
|
|
@@ -43,7 +62,7 @@ async def test_forwarding_retries_connection_failure() -> None: | |||||||||||||||||||||||||||||||
| client._cached_proxy_url = "http://old" | ||||||||||||||||||||||||||||||||
| client._resolve_proxy_url = AsyncMock(side_effect=["http://old", "http://new"]) | ||||||||||||||||||||||||||||||||
| expected = object() | ||||||||||||||||||||||||||||||||
| client._forward = AsyncMock(side_effect=[httpx.ConnectError("unreachable"), expected]) | ||||||||||||||||||||||||||||||||
| client._forward = AsyncMock(side_effect=[_connect_error("unreachable"), expected]) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| result = await client._forward_with_retry(object(), "model", base_model=None) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
@@ -52,19 +71,46 @@ async def test_forwarding_retries_connection_failure() -> None: | |||||||||||||||||||||||||||||||
| assert client._forward.await_count == 2 | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @pytest.mark.asyncio | ||||||||||||||||||||||||||||||||
| async def test_forwarding_retries_transient_5xx_once() -> None: | ||||||||||||||||||||||||||||||||
| client = object.__new__(SkyRLTrainInferenceForwardingClient) | ||||||||||||||||||||||||||||||||
| client._cached_proxy_url = "http://old" | ||||||||||||||||||||||||||||||||
| client._resolve_proxy_url = AsyncMock(side_effect=["http://old", "http://new"]) | ||||||||||||||||||||||||||||||||
| expected = object() | ||||||||||||||||||||||||||||||||
| client._forward = AsyncMock(side_effect=[TransientInferenceError("503 from router"), expected]) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| result = await client._forward_with_retry(object(), "model", base_model=None) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| assert result is expected | ||||||||||||||||||||||||||||||||
| assert client._forward.await_count == 2 | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @pytest.mark.asyncio | ||||||||||||||||||||||||||||||||
| async def test_forwarding_does_not_retry_4xx() -> None: | ||||||||||||||||||||||||||||||||
| client = object.__new__(SkyRLTrainInferenceForwardingClient) | ||||||||||||||||||||||||||||||||
| client._cached_proxy_url = "http://inference" | ||||||||||||||||||||||||||||||||
| client._resolve_proxy_url = AsyncMock(return_value="http://inference") | ||||||||||||||||||||||||||||||||
| client._forward = AsyncMock(side_effect=RuntimeError("vLLM /v1/completions returned 400: bad request")) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| with pytest.raises(RuntimeError, match="returned 400"): | ||||||||||||||||||||||||||||||||
| await client._forward_with_retry(object(), "model", base_model=None) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| client._forward.assert_awaited_once() | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @pytest.mark.asyncio | ||||||||||||||||||||||||||||||||
| async def test_forwarding_does_not_retry_read_timeout() -> None: | ||||||||||||||||||||||||||||||||
| client = object.__new__(SkyRLTrainInferenceForwardingClient) | ||||||||||||||||||||||||||||||||
| client.engine_config = EngineConfig(base_model="test-model", forwarding_inference_timeout_sec=123.0) | ||||||||||||||||||||||||||||||||
| client._cached_proxy_url = "http://inference" | ||||||||||||||||||||||||||||||||
| client._resolve_proxy_url = AsyncMock(return_value="http://inference") | ||||||||||||||||||||||||||||||||
| client._forward = AsyncMock(side_effect=httpx.ReadTimeout("slow response")) | ||||||||||||||||||||||||||||||||
| client._forward = AsyncMock(side_effect=aiohttp.SocketTimeoutError("slow response")) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| with pytest.raises(RuntimeError) as exc_info: | ||||||||||||||||||||||||||||||||
| await client._forward_with_retry(object(), "model", base_model=None) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| message = str(exc_info.value) | ||||||||||||||||||||||||||||||||
| assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout) | ||||||||||||||||||||||||||||||||
| assert isinstance(exc_info.value.__cause__, aiohttp.SocketTimeoutError) | ||||||||||||||||||||||||||||||||
|
Comment on lines
+107
to
+113
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update the test to use the custom
Suggested change
|
||||||||||||||||||||||||||||||||
| assert "http://inference" in message | ||||||||||||||||||||||||||||||||
| assert "timed out after 123s" in message | ||||||||||||||||||||||||||||||||
| client._resolve_proxy_url.assert_awaited_once_with() | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
aiohttplibrary does not defineConnectionTimeoutErrororSocketTimeoutErrorexceptions. Referencing them will raise anAttributeErrorat runtime.To correctly handle and distinguish connection timeouts from read timeouts, we can define custom
ConnectionTimeoutErrorandReadTimeoutErrorexceptions.