Refine sandbox duration coercion with typed and boundary helpers#90
Refine sandbox duration coercion with typed and boundary helpers#90scotttrinh merged 6 commits intomainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR refactors sandbox duration handling to centralize conversion around timedelta, splitting duration normalization into a typed internal helper (coerce_duration) and a boundary-oriented helper (parse_duration), and updates sandbox lifecycle APIs to accept timedelta inputs for timeouts/polling.
Changes:
- Added
MILLISECOND/SECONDconstants pluscoerce_duration()andparse_duration()in the internal sandbox time module. - Updated sandbox lifecycle methods (
wait_for_status,stop(blocking=True)) to accepttimedeltainputs by normalizing to seconds. - Updated internal serialization/validation paths and expanded unit/integration coverage for the new time helpers and lifecycle behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/vercel/_internal/sandbox/time.py |
Introduces MILLISECOND/SECOND and the new coerce_duration/parse_duration helpers. |
src/vercel/sandbox/sandbox.py |
Allows timeout/poll_interval to be `float |
src/vercel/_internal/sandbox/models.py |
Uses parse_duration(..., MILLISECOND) to coerce timeout in the request model. |
src/vercel/_internal/sandbox/core.py |
Serializes extend_timeout(duration=...) using coerce_duration(..., MILLISECOND) // MILLISECOND. |
src/vercel/_internal/sandbox/snapshot.py |
Normalizes snapshot expiration via coerce_duration(..., MILLISECOND) then projects to ms. |
tests/unit/test_sandbox_time.py |
Replaces old tests with coverage for coerce_duration/parse_duration behavior and error cases. |
tests/integration/test_sandbox_sync_async.py |
Adds integration coverage that stop(..., blocking=True) and wait_for_status(...) accept timedelta. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @classmethod | ||
| def _coerce_timeout(cls, value: object) -> int | None: | ||
| if value is None: | ||
| normalized_delta = parse_duration(value, MILLISECOND) |
There was a problem hiding this comment.
parse_duration() accepts float, so this validator will now accept timeout=0.5 (treated as 0.5ms then truncated) even though the field type/docstring is int | timedelta | None (milliseconds). It also means type errors will surface as "duration must ..." rather than mentioning timeout. Consider explicitly rejecting float here (and re-raising with a timeout-specific message) to preserve the previous public contract and clearer validation errors.
| normalized_delta = parse_duration(value, MILLISECOND) | |
| if value is None: | |
| return None | |
| if isinstance(value, float): | |
| raise ValueError("timeout must be an int, timedelta, or None") | |
| try: | |
| normalized_delta = parse_duration(value, MILLISECOND) | |
| except (TypeError, ValueError) as exc: | |
| raise ValueError("timeout must be an int, timedelta, or None") from exc |
| @@ -16,9 +16,8 @@ class SnapshotExpiration(int): | |||
| """ | |||
|
|
|||
| def __new__(cls, value: int | timedelta) -> SnapshotExpiration: | |||
There was a problem hiding this comment.
SnapshotExpiration is typed as int | timedelta, but coerce_duration() also accepts float, so SnapshotExpiration(0.5) will now be accepted and truncated to milliseconds. If fractional milliseconds are not intended here, add an explicit float rejection (and ideally an error message that mentions snapshot expiration rather than the generic "duration").
| def __new__(cls, value: int | timedelta) -> SnapshotExpiration: | |
| def __new__(cls, value: int | timedelta) -> SnapshotExpiration: | |
| if isinstance(value, float): | |
| raise TypeError( | |
| "Snapshot expiration must be an integer number of milliseconds or a timedelta" | |
| ) |
| f"/v1/sandboxes/{sandbox_id}/extend-timeout", | ||
| body=JSONBody({"duration": normalize_duration_ms(duration)}), | ||
| body=JSONBody({"duration": coerce_duration(duration, MILLISECOND) // MILLISECOND}), | ||
| ) |
There was a problem hiding this comment.
extend_timeout() is typed as int | timedelta, but coerce_duration() will also accept float, which would then be truncated via // MILLISECOND and sent on the wire. If the API expects integer milliseconds (as the type suggests), consider explicitly rejecting float here to avoid silently accepting unintended inputs.
Considered putting the conversion back to a numeric in this function directly, but without a first-class `Option` type, you already had to guard against `None`, so it didn't make much sense to me. That would've looked like: ```py coerced = coerce_duration(value, MILLISECOND, as_type=int) ``` vs ```py coerced = coerce_duration(value, MILLISECOND) // MILLISECOND if value is None: coerced = None else: coerced = coerce_duration(value, MILLISECOND) // MILLISECOND ```
Keep CreateSandboxRequest timeout values as timedelta inside the model and serialize them to millisecond integers at model_dump time. This also adds focused coverage for the internal timedelta shape and serialized request payload.
f7d449e to
7115dca
Compare
| "POST", | ||
| f"/v1/sandboxes/{sandbox_id}/extend-timeout", | ||
| body=JSONBody({"duration": normalize_duration_ms(duration)}), | ||
| body=JSONBody({"duration": coerce_duration(duration, MILLISECOND) // MILLISECOND}), |
There was a problem hiding this comment.
I'd define a helper vercel._internal.sandbox.time.to_ms()
There was a problem hiding this comment.
What would it take as an input? A timedelta?
duration = coerce_duration(value, MILLISECOND)
ms = to_ms(duration)That's not much different from:
duration = timedelta(millisecond=value)
ms = duration // MILLISECONDAlthough arguably more clear.
If we are trying to wrap the whole thing up, coercion through numeric conversion, I couldn't come up with a unified API that was type safe and didn't just include every combination of input type, what unit that value represents, and the target output type.
There was a problem hiding this comment.
OK I'm a bit lost now... how is
duration = timedelta(millisecond=value)
ms = duration // MILLISECOND
different from
ms = value
?
There was a problem hiding this comment.
yeah, fair, this is definitely not the most obvious thing on first read.
The short version is that it's doing boundary normalization. Our public APIs now accept timedelta in addition to bare numerics like int or float, but when you pass a bare 5, it doesn't tell you whether it's seconds or milliseconds. So the call site provides the unit, we convert to a timedelta, and from there everything internally carries its own unit. At the consumer end we convert out to whatever's needed — td // MILLISECOND for API payloads, td / SECOND for sleep-style calls.
The main alternative would be to normalize in the other direction: convert incoming timedelta to a numeric type at the boundary and carry that through. That's a valid design and would be simpler in some of today's call sites, since we always end up at int ms or float seconds anyway. I just prefer keeping timedelta as the internal representation because it means duration values carry their own unit instead of relying on convention.
fwiw I'm cool to add a couple of read-side helpers like to_ms_int(td) and to_seconds_float(td) since those are really the only two cases we need today. I'll do that and you can tell me what you think.
There was a problem hiding this comment.
This is fine. Just add a short comment.
1st1
left a comment
There was a problem hiding this comment.
I'll approve this but please address the comments, particularly the subclassing int one - we shouldn't do that.
Avoid weird type and runtime issues that come from subclassing a built-in like `int`, but keep the same value-like semantics via `__eq__` and `__hash__`. Also, store the timedelta itself and only convert to `int` when needed.
Also tighten up the `extend_timeout` interface to normalize closer to the boundary.
Not sure why this is only failing for me and not in CI, but whatever!
This PR cleans up the internal duration-conversion API used by the sandbox client.
The main outcome is a deliberate split between two helpers in
src/vercel/_internal/sandbox/time.py:coerce_duration(value, unit)for typed internal call sitesparse_duration(value, unit)for untyped boundary inputs such as Pydantic field validatorsIt also exports unit constants:
MILLISECONDSECONDThis keeps the core conversion logic generic without continuing to grow a family of unit-specific helpers.
Current API
The current shape is:
For untyped boundary inputs:
This makes two things explicit:
timedelta)Why split
coerce_durationandparse_durationcoerce_duration()is intentionally typed:That makes internal call sites crisp and keeps type checking useful.
parse_duration()is intentionally boundary-oriented:That fits places like Pydantic validators, where the input is genuinely
objectand may be invalid orNone.This avoids pushing boundary-validation concerns into the typed helper.
None handling
We explored letting
coerce_duration()acceptNone, but it made the ergonomics worse for normal internal call sites:/ SECONDor// MILLISECONDThe final decision was:
coerce_duration()does not handleNoneparse_duration()does handleNoneThat keeps the internal helper simple while still supporting boundary-facing validators cleanly.
Alternatives considered
1. Keep adding more specialized helpers
Example API:
Pros:
Cons:
This is the direction the code was already drifting toward, and this PR intentionally moves away from it.
2. Add an
as_typekwarg tocoerce_durationExample API:
Pros:
Cons:
/and//timedeltainputs are involved, because the intended output shape is policy, not something recoverable from the inputThis felt more clever than clear.
3. Accept a
projectortransformlambdaExample API:
Pros:
Cons:
We decided this was possible, but not better.
Why the current shape won
The chosen API keeps the split simple:
coerce_duration()answers “how do I turn a typed duration input into atimedelta?”parse_duration()answers “how do I safely accept an untyped boundary value and maybe get atimedelta?”Then each caller makes its numeric projection explicit:
/ SECONDfor fractional seconds// MILLISECONDfor integer millisecondsThat keeps unit semantics visible at the call site and avoids making one helper carry too much policy.
Closes #86