Skip to content

Refine sandbox duration coercion with typed and boundary helpers#90

Merged
scotttrinh merged 6 commits intomainfrom
sandbox/86-more-timedelta
Apr 15, 2026
Merged

Refine sandbox duration coercion with typed and boundary helpers#90
scotttrinh merged 6 commits intomainfrom
sandbox/86-more-timedelta

Conversation

@scotttrinh
Copy link
Copy Markdown
Collaborator

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 sites
  • parse_duration(value, unit) for untyped boundary inputs such as Pydantic field validators

It also exports unit constants:

  • MILLISECOND
  • SECOND

This keeps the core conversion logic generic without continuing to grow a family of unit-specific helpers.

Current API

The current shape is:

delta = coerce_duration(timeout, SECOND)
seconds = delta / SECOND
delta = coerce_duration(duration, MILLISECOND)
milliseconds = delta // MILLISECOND

For untyped boundary inputs:

normalized_delta = parse_duration(value, MILLISECOND)
if normalized_delta is None:
    return None
return normalized_delta // MILLISECOND

This makes two things explicit:

  • coercion into a canonical internal representation (timedelta)
  • projection back into the numeric form the caller actually wants

Why split coerce_duration and parse_duration

coerce_duration() is intentionally typed:

def coerce_duration(value: int | float | timedelta, unit: timedelta) -> timedelta

That makes internal call sites crisp and keeps type checking useful.

parse_duration() is intentionally boundary-oriented:

def parse_duration(value: object, unit: timedelta) -> timedelta | None

That fits places like Pydantic validators, where the input is genuinely object and may be invalid or None.

This avoids pushing boundary-validation concerns into the typed helper.

None handling

We explored letting coerce_duration() accept None, but it made the ergonomics worse for normal internal call sites:

  • it forced optional return types into places that did not need them
  • it required extra guards or assertions before applying / SECOND or // MILLISECOND

The final decision was:

  • coerce_duration() does not handle None
  • parse_duration() does handle None

That keeps the internal helper simple while still supporting boundary-facing validators cleanly.

Alternatives considered

1. Keep adding more specialized helpers

Example API:

seconds = normalize_duration_seconds(timeout)
milliseconds = normalize_duration_ms(duration)

Pros:

  • compact call sites
  • easy to read in isolation

Cons:

  • the helper family keeps growing by unit and output shape
  • the real shared abstraction stays hidden
  • tests and behavior tend to duplicate across helpers

This is the direction the code was already drifting toward, and this PR intentionally moves away from it.

2. Add an as_type kwarg to coerce_duration

Example API:

seconds = coerce_duration(timeout, SECOND, as_type=float)
milliseconds = coerce_duration(duration, MILLISECOND, as_type=int)

Pros:

  • one entry point
  • hides the projection step

Cons:

  • mixes coercion and numeric projection again
  • makes the helper responsible for deciding between / and //
  • gets awkward once timedelta inputs are involved, because the intended output shape is policy, not something recoverable from the input

This felt more clever than clear.

3. Accept a project or transform lambda

Example API:

seconds = coerce_duration(timeout, SECOND, project=lambda d: d / SECOND)
milliseconds = coerce_duration(duration, MILLISECOND, project=lambda d: d // MILLISECOND)

Pros:

  • flexible
  • can hide optional-style mapping behavior inside the helper

Cons:

  • pushes more abstraction into a helper that should stay simple
  • makes straightforward unit conversion harder to scan at the call site
  • effectively reintroduces “option map” behavior without much Pythonic benefit

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 a timedelta?”
  • parse_duration() answers “how do I safely accept an untyped boundary value and maybe get a timedelta?”

Then each caller makes its numeric projection explicit:

  • / SECOND for fractional seconds
  • // MILLISECOND for integer milliseconds

That keeps unit semantics visible at the call site and avoids making one helper carry too much policy.

Closes #86

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vercel-py Ready Ready Preview Apr 15, 2026 1:20pm

Request Review

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/SECOND constants plus coerce_duration() and parse_duration() in the internal sandbox time module.
  • Updated sandbox lifecycle methods (wait_for_status, stop(blocking=True)) to accept timedelta inputs 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.

Comment thread src/vercel/_internal/sandbox/models.py Outdated
@classmethod
def _coerce_timeout(cls, value: object) -> int | None:
if value is None:
normalized_delta = parse_duration(value, MILLISECOND)
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
@@ -16,9 +16,8 @@ class SnapshotExpiration(int):
"""

def __new__(cls, value: int | timedelta) -> SnapshotExpiration:
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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").

Suggested change
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"
)

Copilot uses AI. Check for mistakes.
Comment thread src/vercel/_internal/sandbox/core.py Outdated
Comment on lines 505 to 512
f"/v1/sandboxes/{sandbox_id}/extend-timeout",
body=JSONBody({"duration": normalize_duration_ms(duration)}),
body=JSONBody({"duration": coerce_duration(duration, MILLISECOND) // MILLISECOND}),
)
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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.
Comment thread src/vercel/_internal/sandbox/core.py Outdated
"POST",
f"/v1/sandboxes/{sandbox_id}/extend-timeout",
body=JSONBody({"duration": normalize_duration_ms(duration)}),
body=JSONBody({"duration": coerce_duration(duration, MILLISECOND) // MILLISECOND}),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd define a helper vercel._internal.sandbox.time.to_ms()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 // MILLISECOND

Although 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK I'm a bit lost now... how is

duration = timedelta(millisecond=value)
ms = duration // MILLISECOND

different from

ms = value

?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine. Just add a short comment.

Comment thread src/vercel/_internal/sandbox/snapshot.py Outdated
Copy link
Copy Markdown
Member

@1st1 1st1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use timedelta across sandbox duration APIs

3 participants