[tinker] Retry external sample requests through the lazy adapter-load race#1847
[tinker] Retry external sample requests through the lazy adapter-load race#1847john-hahn wants to merge 2 commits into
Conversation
… race ExternalInferenceClient extracts a LoRA checkpoint and references it by name, relying on lora_filesystem_resolver to load it lazily on first reference. The first /completions for a freshly extracted adapter can race that load and 4xx, failing the sample request. Wrap the call in a bounded retry scoped to the 'adapter not loaded yet' signal (404, 400-model-missing, transport errors), proactively loading the adapter before retrying; genuine 4xx and all 5xx surface immediately. Adds tests/tinker/test_external_inference.py.
There was a problem hiding this comment.
Code Review
This pull request introduces a self-healing retry mechanism for the external inference client to handle lazy-loading of LoRA adapters. When a sample request fails with a 404 or a specific 400 error, the client proactively requests the engine to load the adapter and retries the request. Unit tests are also added to verify this behavior. The review feedback suggests setting an explicit timeout on the proactive adapter-load request to prevent blocking the retry loop, and avoiding retries on 404/400 errors when sampling from a base model, as these represent permanent configuration errors rather than transient lazy-load races.
| try: | ||
| await http_client.post( | ||
| "/load_lora_adapter", | ||
| json={"lora_name": model_name, "lora_path": str(self.lora_base_dir / model_name)}, | ||
| ) |
There was a problem hiding this comment.
The proactive load request uses the default timeout of the http_client, which is configured to be 300 seconds (5 minutes). If the /load_lora_adapter endpoint hangs or responds very slowly, it will block the entire retry loop for a long time, defeating the purpose of a fast retry/fail. Since this proactive load is a best-effort call, we should set a shorter, explicit timeout (e.g., 10 seconds).
| try: | |
| await http_client.post( | |
| "/load_lora_adapter", | |
| json={"lora_name": model_name, "lora_path": str(self.lora_base_dir / model_name)}, | |
| ) | |
| try: | |
| await http_client.post( | |
| "/load_lora_adapter", | |
| json={"lora_name": model_name, "lora_path": str(self.lora_base_dir / model_name)}, | |
| timeout=10.0, | |
| ) |
| def _is_retriable_sample_error(exc: Exception) -> bool: | ||
| """Return whether a failed sample request should be retried after a proactive adapter load. | ||
|
|
||
| The engine loads LoRA adapters lazily, so a freshly extracted adapter can be sampled before it is | ||
| registered. Retry transient transport errors and the engine's "adapter not loaded yet" responses | ||
| (a 404, or a 400 whose body reports the model does not exist). Malformed requests and server | ||
| errors are not retried. | ||
| """ | ||
| if isinstance(exc, httpx.TransportError): | ||
| return True | ||
| if isinstance(exc, httpx.HTTPStatusError): | ||
| status_code = exc.response.status_code | ||
| if status_code == 404: | ||
| return True | ||
| if status_code == 400: | ||
| body = exc.response.text.lower() | ||
| return "does not exist" in body or "not found" in body | ||
| return False |
There was a problem hiding this comment.
When sampling from a base model (base_model is not None), a 404 or 400 indicating that the model does not exist is a permanent configuration error (e.g., a misspelled model name), not a transient lazy-load race. Retrying these errors for base models wastes time and delays error reporting. We should only retry 404/400 errors when sampling a LoRA adapter (base_model is None), while still retrying transient transport errors for both.
| def _is_retriable_sample_error(exc: Exception) -> bool: | |
| """Return whether a failed sample request should be retried after a proactive adapter load. | |
| The engine loads LoRA adapters lazily, so a freshly extracted adapter can be sampled before it is | |
| registered. Retry transient transport errors and the engine's "adapter not loaded yet" responses | |
| (a 404, or a 400 whose body reports the model does not exist). Malformed requests and server | |
| errors are not retried. | |
| """ | |
| if isinstance(exc, httpx.TransportError): | |
| return True | |
| if isinstance(exc, httpx.HTTPStatusError): | |
| status_code = exc.response.status_code | |
| if status_code == 404: | |
| return True | |
| if status_code == 400: | |
| body = exc.response.text.lower() | |
| return "does not exist" in body or "not found" in body | |
| return False | |
| def _is_retriable_sample_error(exc: Exception, *, is_lora: bool = True) -> bool: | |
| """Return whether a failed sample request should be retried after a proactive adapter load. | |
| The engine loads LoRA adapters lazily, so a freshly extracted adapter can be sampled before it is | |
| registered. Retry transient transport errors and the engine's "adapter not loaded yet" responses | |
| (a 404, or a 400 whose body reports the model does not exist). Malformed requests and server | |
| errors are not retried. | |
| """ | |
| if isinstance(exc, httpx.TransportError): | |
| return True | |
| if is_lora and isinstance(exc, httpx.HTTPStatusError): | |
| status_code = exc.response.status_code | |
| if status_code == 404: | |
| return True | |
| if status_code == 400: | |
| body = exc.response.text.lower() | |
| return "does not exist" in body or "not found" in body | |
| return False |
| for attempt in range(_MAX_SAMPLE_RETRIES): | ||
| try: | ||
| response = await http_client.post("/completions", json=payload, headers=headers) | ||
| response.raise_for_status() | ||
| return response.json() | ||
| except (httpx.HTTPStatusError, httpx.TransportError) as exc: | ||
| if attempt == _MAX_SAMPLE_RETRIES - 1 or not _is_retriable_sample_error(exc): | ||
| raise | ||
| if base_model is None: | ||
| await self._request_adapter_load(http_client, model_name) | ||
| await asyncio.sleep(min(_RETRY_MAX_DELAY_SEC, _RETRY_BASE_DELAY_SEC * (2**attempt))) |
There was a problem hiding this comment.
Pass the is_lora flag to _is_retriable_sample_error to avoid retrying permanent 404/400 errors for base models.
| for attempt in range(_MAX_SAMPLE_RETRIES): | |
| try: | |
| response = await http_client.post("/completions", json=payload, headers=headers) | |
| response.raise_for_status() | |
| return response.json() | |
| except (httpx.HTTPStatusError, httpx.TransportError) as exc: | |
| if attempt == _MAX_SAMPLE_RETRIES - 1 or not _is_retriable_sample_error(exc): | |
| raise | |
| if base_model is None: | |
| await self._request_adapter_load(http_client, model_name) | |
| await asyncio.sleep(min(_RETRY_MAX_DELAY_SEC, _RETRY_BASE_DELAY_SEC * (2**attempt))) | |
| is_lora = base_model is None | |
| for attempt in range(_MAX_SAMPLE_RETRIES): | |
| try: | |
| response = await http_client.post("/completions", json=payload, headers=headers) | |
| response.raise_for_status() | |
| return response.json() | |
| except (httpx.HTTPStatusError, httpx.TransportError) as exc: | |
| if attempt == _MAX_SAMPLE_RETRIES - 1 or not _is_retriable_sample_error(exc, is_lora=is_lora): | |
| raise | |
| if is_lora: | |
| await self._request_adapter_load(http_client, model_name) | |
| await asyncio.sleep(min(_RETRY_MAX_DELAY_SEC, _RETRY_BASE_DELAY_SEC * (2**attempt))) |
| def test_base_model_never_loads_adapter(monkeypatch): | ||
| """Base-model sampling retries transient errors but never calls the adapter-load endpoint.""" | ||
| scripted = _ScriptedClient([404, 200]) | ||
| result = _run(_make_client(), scripted, monkeypatch, base_model="base") | ||
| assert result == {"choices": []} | ||
| assert "/load_lora_adapter" not in scripted.calls |
There was a problem hiding this comment.
Update the base model test to verify that transient transport errors are retried, but permanent 404 errors are not retried.
| def test_base_model_never_loads_adapter(monkeypatch): | |
| """Base-model sampling retries transient errors but never calls the adapter-load endpoint.""" | |
| scripted = _ScriptedClient([404, 200]) | |
| result = _run(_make_client(), scripted, monkeypatch, base_model="base") | |
| assert result == {"choices": []} | |
| assert "/load_lora_adapter" not in scripted.calls | |
| def test_base_model_retries_transport_error(monkeypatch): | |
| """Base-model sampling retries transient transport errors but never calls the adapter-load endpoint. | |
| """ | |
| scripted = _ScriptedClient([httpx.ConnectError("boom"), 200]) | |
| result = _run(_make_client(), scripted, monkeypatch, base_model="base") | |
| assert result == {"choices": []} | |
| assert "/load_lora_adapter" not in scripted.calls | |
| def test_base_model_does_not_retry_404(monkeypatch): | |
| """Base-model sampling does not retry on 404 (model not found).""" | |
| scripted = _ScriptedClient([404, 200]) | |
| with pytest.raises(httpx.HTTPStatusError): | |
| _run(_make_client(), scripted, monkeypatch, base_model="base") |
Per review on NovaSky-AI#1847: - Do not retry 404/400 'model does not exist' for base-model sampling; that is a permanent configuration error, not the lazy adapter-load race. Thread an is_lora flag into _is_retriable_sample_error (transport errors still retry for both LoRA and base-model). - Give the proactive /load_lora_adapter call an explicit 30s timeout so a hung load endpoint cannot block the retry loop for the client's full 300s. - Tests: cover base-model no-retry-on-404 and still-retry-on-transport-error; accept **kwargs in the stubbed client so the timed load call is recorded.
[tinker] Retry external sample requests through the lazy adapter-load race
Summary
ExternalInferenceClient._forward_to_engineextracts a LoRA checkpoint toexternal_inference_lora_base, references it by name, and relies on thelora_filesystem_resolverplugin to load it lazily on the engine's first reference to that name. The first
/completionsrequest for a freshly extracted adapter can be issued before the engine has registered it and come
back as a 404 (or a 400 reporting the model does not exist), which currently fails the whole sample
request.
This wraps the completion call in a bounded retry. On the "adapter not loaded yet" signal — a 404, a
400 whose body reports a missing model, or a transport error — it proactively asks the engine to
load the adapter (
POST /load_lora_adapter) and retries with exponential backoff. Other clienterrors (e.g. an over-length request) and server errors are surfaced immediately.
Changes
_is_retriable_sample_errorand retry constants toexternal_inference.py._request_adapter_loadand_post_completionmethods onExternalInferenceClient; route thecompletion call in
_forward_to_enginethrough_post_completion.tests/tinker/test_external_inference.py.Design notes
errors. A 4xx that is a genuine client error and any 5xx are re-raised on the first occurrence, so
real failures are not masked or delayed.
surfacing.
base_model is None) and is best-effort —if the endpoint is disabled or runtime LoRA updating is off, the retry alone still gives the
filesystem resolver time to register the adapter.
POST /load_lora_adapterresolves under theclient's
/v1base, matching vLLM's dynamic-LoRA endpoint.Testing
uv run --extra dev --extra jax pytest tests/tinker/test_external_inference.py—passing (10 cases: retry predicate for 404 / 400-missing / 400-other / 403 / 500 / transport;
happy path, self-heal-then-succeed, retry-exhaustion, no-retry-on-5xx, base-model-never-loads).
Checklist
.claude/docs; no example-script or doc changes needed.<= 120cols andast-parse clean;bash format.sh(ruff + black) to be run in-repo before opening.