fix(sandbox): propagate control-plane errors from update_*/delete (ENG-4244) - #206
Conversation
…G-4244) Generated control-plane API functions return `Union[Error, T] | None`, so an error status is a return value rather than an exception. The sandbox update_* and delete helpers passed that response straight into `cls(response)`, which does not type-check, so a 403/404/500 produced an instance wrapping an `Error`. Callers that ignore the return value -- the common shape -- saw a failed write as a success: the TTL was never applied, or the sandbox was never deleted and kept billing. Callers that used the result hit `AttributeError: 'Error' object has no attribute 'metadata'` far from the real cause. Route the responses through `_unwrap_response()`, already present in the same module and already imported by the sync tree; it was added in #201 for the fork/snapshot helpers and never applied to the update/delete paths. Volume delete had the identical gap and uses that file's existing guard shape. Reported by Mathis Joffre. TypeScript (`throwOnError: true`) and Go are already correct; Python was the only affected SDK. Note: delete on a 404 now raises `SandboxAPIError` instead of silently returning an `Error`, and the empty case raises `SandboxAPIError` instead of `ValueError`.
Mendral review on #206: the volume delete helpers guarded Error but not None, while the sandbox path routes through _unwrap_response, which raises on both. delete_volume returns Union[Error, Volume] | None, so a None response returned silently despite the -> Volume annotation -- the same silent-failure class this PR removes for sandboxes.
🧪 Testing GuideWhat this PR addressesThe generated control-plane API functions return
The same issue existed for volume delete helpers. Steps to reproduce the original issue
What to verify (expected behavior)
Note Posted by PR Testing Guide · Tag @mendral-app with feedback. |
test_update_lifecycle_with_different_policy_types_preserves_files built a lifecycle with ttl-idle listed twice. The control plane rejects duplicate policy types with a 400, and update_lifecycle used to swallow that 400, so the update silently never applied. The test then asserted the file survived a change that had not happened, and passed for the wrong reason. Now that update_lifecycle raises, the 400 surfaces and the copy-paste duplicate is visible. Drop the duplicate entry so the test exercises the policy-type change it claims to.
There was a problem hiding this comment.
LGTM
The only new change since my last LGTM review is a single integration test fix that removes an invalid duplicate policy entry. The fix is correct and well-commented. Previous review comments were already addressed in 082b3aa.
Tag @mendral-app with feedback or questions. View session
Reported by Mathis Joffre in the shared EvenUp channel.
What broke
Every generated control-plane API function returns
Union[Error, T] | None, so an error status is a return value, not an exception.SandboxInstance.update_metadata / update_ttl / update_lifecycle / update_networkanddeletepassed that response straight intocls(response).SandboxInstance.__init__does not type-check, so it built an instance wrapping anErrorand constructed.fs,.process,.previewsover it.Two failure modes, the first worse than how it was reported:
update_ttlreturned normally and the TTL was never applied. A faileddeletereturned normally and the sandbox stayed alive and kept billing. No exception, no log.AttributeError: 'Error' object has no attribute 'metadata'arbitrarily far from the real cause.Reproduced against a mocked control plane on
main@2c405e2:Confirmed present in the published
blaxel==0.3.5wheel, so this is live for customers rather than only onmain.Scope
sandbox/default/sandbox.pyupdate_*unguardedsandbox/default/sandbox.pydeleteonly checkedNone, then raised a misleadingValueError("Sandbox X not found")for what may have been a 500sandbox/sync/sandbox.pySyncSandboxInstancesandbox/sync/sandbox.pydeletehad no check at allvolume/volume.py_delete_volume_by_name/_sync, identical shape and identical generatedUnion[Error, Volume]typecreate,get,list,fork,snapshot*were already correctly guarded.Supporting evidence that this already bites us internally:
tests/integration/core/conftest.pycarries an_api_error(response)helper that sniffs returned values for a leakedErrorduring sandbox cleanup.Fix
Route the responses through
_unwrap_response(), which already exists insandbox/default/sandbox.pyand is already imported by the sync tree. It was added in #201 for the fork/snapshot helpers and simply never applied to the update/delete paths. Volume delete uses the guard shape already repeated six times in that file, so no new abstraction is introduced there either.17 lines added and 4 removed across three source files. The
_unwrap_responsedocstring is updated to state the rule, since it now governs every write path rather than only fork/snapshot.Behavior change
deleteon a 404 now raisesSandboxAPIErrorinstead of silently returning anError, and the empty response raisesSandboxAPIErrorinstead ofValueError. This is the intended correction, but it is breaking for anyone relying on best-effort deletes.Cross-SDK parity
@blaxel/core/src/sandbox/sandbox.tspassesthrowOnError: trueon everyupdateSandbox/deleteSandbox. Already correct.(res, err). Already correct.Verification
main(stale local device-mode credentials hittingapi.blaxel.ai/v0/oauth/token), confirmed by stashing the patch and re-running.make lint(ruff check --fix): all checks passed. Note thatruff formatis deliberately not run, since it reformats 98 unrelated generated files and is not part of thelinttarget.httpxover a mocked transport: all five paths now raiseSandboxAPIError, and the 200 control path still returns a properSandboxInstance.Coverage added: each of the 4
update_*x {Error,None} x {async, sync}, class-level and instance-leveldelete, volume delete in both trees, plus a success-path test so the guard cannot regress into "always raises".Out of scope
drive/drive.py. The drives OpenAPI declares no error schema, so the generated_parse_responsereturnscast(Any, None)for 401/404. The sixisinstance(response, Error)guards in that file are dead code that can never fire, and drive errors silently collapse toNone. That needs a contract correction plus regeneration, sibling to ENG-4050 / ENG-4072, and is tracked separately.Linear: ENG-4244
Note
Adds a test fix (commit
66cb48b) that removes a duplicatettl-idlepolicy entry from an integration test. The control plane rejects duplicate policy types with a 400, which was previously swallowed silently — now thatupdate_lifecycleraises, the duplicate surfaced as a test failure.Written by Mendral for commit 66cb48b.