fix(sandbox): health-check on connect in the OpenSandbox provider - #2345
Conversation
`OpenSandboxProvider.connect()` hardcoded `skip_health_check=True`, so a handle rebuilt from a sandbox id was handed back without checking that the sandbox was reachable. A sandbox id only proves the workload exists. The server reports a sandbox ready once its pod is Running with an IP, which happens before execd binds its port, so an unchecked handle defers that startup gap to the first real call -- where it surfaces as `502 Could not connect to the backend sandbox endpoint=...:44772` instead of a short wait. Measured on a production cell during a 1,452-sandbox burst: 31% of sandboxes saw at least one 502 on the execd port, clearing after p50 11s / p90 28s / p99 55s. Clients whose readiness gate ran absorbed this invisibly on the ping endpoint; clients reaching a sandbox through `connect()` had no gate at all and took the failure on real commands. `connect()` now honours the existing `skip_health_check` setting, which defaults to False, so the SDK polls until the sandbox answers. Callers that deliberately want an unchecked handle can still opt out, and the setting now behaves consistently between `create()` and `connect()`. Note that `connect()` bounds the wait with `connect_attempt_timeout_s` (default 30s); deployments seeing p99-scale startup gaps may want to raise it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Hemil Desai <hemild@nvidia.com>
The reconnect inside `_connect_after_create` still hardcoded `skip_health_check=True`, and two shipped configs turned the check off, so the health check was effectively opt-in rather than opt-out. All call sites now derive the flag from configuration, whose default is False, and the configs that disabled it are flipped back on. Skipping the check lets the first command race a pod whose exec daemon is not listening yet, which returns a 502. Both configs also raise `create.timeout_s` above their `ready_timeout_s`, matching the guidance already documented in the provider's own reference config: that timeout bounds the whole create call, which now includes the readiness wait, so leaving the two equal would turn the wait into a timeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Hemil Desai <hemild@nvidia.com>
Comments state the why only; the rationale is not repeated at each site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Hemil Desai <hemild@nvidia.com>
|
/claude review |
|
/ok to test 6e6a63f |
|
SHIP — no reliability concerns. Tightens OpenSandbox reconnect correctness.
Correctness-improving change with matching test coverage. No async, API-compat, or config-convention concerns. |
|
/claude review |
|
SHIP — LGTM, no reliability concerns. Correct, well-scoped fix. The OpenSandbox provider's reattach paths ( Checked and clean:
No async, verifier, or public-API concerns. |
…IDIA-NeMo#2345) ## What The OpenSandbox provider health-checks by default now: - `connect()` and the reconnect in `_connect_after_create()` no longer hardcode `skip_health_check=True`; both derive it from configuration, whose default is `False`. - Two shipped configs that turned the check off (`litmus_agent.yaml`, and the example in the `mini_swe_agent_2` README) are flipped back on. ## Why A sandbox id only proves the workload exists, not that its exec daemon is listening. The server reports a sandbox ready once its pod is Running with an IP, which happens before the daemon binds its port. A handle returned without a health check defers that gap to the first real call, where it surfaces as: ``` 502 {"code":"GENERAL::UNKNOWN_ERROR","message":"Could not connect to the backend sandbox endpoint=..."} ``` Because `connect()` skipped the check unconditionally, the setting was effectively opt-in rather than opt-out, and the two paths disagreed: `create()` honoured `skip_health_check` while `connect()` ignored it. `_verify_created_handle()` did not cover this either — it is a no-op unless `probe.command` is configured. This mirrors the reasoning already documented in the provider's own reference config (`configs/opensandbox.yaml`), which sets `skip_health_check: false`; this PR brings the code and the remaining configs in line with it. ## Behaviour change `connect()` now waits for the sandbox to answer instead of returning immediately, so it can raise where it previously returned an unusable handle. That is the intent: fail or wait at connect, rather than error on the first command. The opt-out is preserved via `skip_health_check` for callers that deliberately want an unchecked handle. Both updated configs also raise `create.timeout_s` above their `ready_timeout_s`, following the guidance in the reference config: that timeout bounds the whole create call, which now includes the readiness wait, so leaving the two equal would turn the wait into a timeout. ## Testing - Added `test_connect_health_checks_by_default` and `test_connect_honours_skip_health_check_opt_out`. - Updated two existing assertions that encoded the old hardcoded value. - `tests/unit_tests/test_opensandbox_provider.py` and `tests/unit_tests/test_sandbox.py`: all pass except `test_resolve_provider_config_named_reference`, which fails on `ModuleNotFoundError: No module named 'omegaconf'` and was confirmed pre-existing by reproducing it with this change stashed. Local runs used a minimal virtualenv: this repo pins a Python version the local toolchain could not fetch, and one dependency does not build on the newer interpreter available. Full-matrix verification is left to CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Hemil Desai <hemild@nvidia.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
The OpenSandbox provider health-checks by default now:
connect()and the reconnect in_connect_after_create()no longer hardcodeskip_health_check=True; both derive it from configuration, whose default isFalse.litmus_agent.yaml, and the example in themini_swe_agent_2README) are flipped back on.Why
A sandbox id only proves the workload exists, not that its exec daemon is listening. The server reports a sandbox ready once its pod is Running with an IP, which happens before the daemon binds its port. A handle returned without a health check defers that gap to the first real call, where it surfaces as:
Because
connect()skipped the check unconditionally, the setting was effectively opt-in rather than opt-out, and the two paths disagreed:create()honouredskip_health_checkwhileconnect()ignored it._verify_created_handle()did not cover this either — it is a no-op unlessprobe.commandis configured.This mirrors the reasoning already documented in the provider's own reference config (
configs/opensandbox.yaml), which setsskip_health_check: false; this PR brings the code and the remaining configs in line with it.Behaviour change
connect()now waits for the sandbox to answer instead of returning immediately, so it can raise where it previously returned an unusable handle. That is the intent: fail or wait at connect, rather than error on the first command.The opt-out is preserved via
skip_health_checkfor callers that deliberately want an unchecked handle.Both updated configs also raise
create.timeout_sabove theirready_timeout_s, following the guidance in the reference config: that timeout bounds the whole create call, which now includes the readiness wait, so leaving the two equal would turn the wait into a timeout.Testing
test_connect_health_checks_by_defaultandtest_connect_honours_skip_health_check_opt_out.tests/unit_tests/test_opensandbox_provider.pyandtests/unit_tests/test_sandbox.py: all pass excepttest_resolve_provider_config_named_reference, which fails onModuleNotFoundError: No module named 'omegaconf'and was confirmed pre-existing by reproducing it with this change stashed.Local runs used a minimal virtualenv: this repo pins a Python version the local toolchain could not fetch, and one dependency does not build on the newer interpreter available. Full-matrix verification is left to CI.
🤖 Generated with Claude Code