Skip to content

Commit ddbaa36

Browse files
nalekseev-e2be2b-bot[bot]
authored andcommitted
feat(api): add idempotency to v2 sandbox creation
GitOrigin-RevId: 4c0bb20c7a9f1e7138754bf877d5f307d307c501
1 parent 6e2cbad commit ddbaa36

27 files changed

Lines changed: 3557 additions & 361 deletions

‎docs/ARCHITECTURE.md‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,103 @@ gets a response, the sandbox is fully usable. Fresh creates are internally a *re
507507
template's base snapshot (cold boots happen for filesystem-only templates and builds, or when
508508
an explicit resume requests one — see pause and resume below; template creates never do).
509509

510+
#### Opt-in request idempotency
511+
512+
`POST /v2/sandboxes` supports an optional, team-scoped `Idempotency-Key` to prevent retries from
513+
repeating sandbox creation while a Redis reservation exists. A generic executor owns reservation
514+
and response replay across API replicas. Participating handlers parse and authorize the request,
515+
then call `idempotency.Execute(c, requestContent, operation)` with all mutations inside the callback.
516+
The executor buffers finite HTTP responses, so streaming and connection upgrades cannot use it.
517+
518+
##### Admission and request identity
519+
520+
The `api-idempotency-routes` JSON flag enables participating handlers by HTTP method and Gin route
521+
pattern. For sandbox creation:
522+
523+
```json
524+
{"POST /v2/sandboxes": true}
525+
```
526+
527+
The flag defaults to null, which disables idempotency. The idempotency middleware bypasses requests
528+
without a key, an authenticated team, or an enabled route. Authentication, schema validation,
529+
rate limits, and blocked-team checks apply before the executor, including on retries. A key must
530+
contain one value of 1 to 255 lowercase ASCII letters or digits (`a-z`, `0-9`). The middleware
531+
validates keys on enabled routes; OpenAPI validation and parameter binding enforce the create
532+
endpoint's header contract regardless of the flag.
533+
534+
The request fingerprint hashes the HTTP method, concrete URI including query string, and
535+
handler-supplied JSON-serializable content with SHA-256. Handlers include behavior-affecting
536+
headers or caller information in that content. Sandbox creation fingerprints its parsed request
537+
after schema defaults, so whitespace and map ordering do not change identity; array order and
538+
parsed field values do. Template aliases and business configuration resolve during execution.
539+
540+
##### Reservation and replay
541+
542+
The executor reserves a Redis hash keyed by team ID and the SHA-256 digest of the idempotency key.
543+
The namespace spans participating routes within a team. An atomic reservation script selects one
544+
owner across API replicas and stores the request fingerprint, a random owner token, and the
545+
response deadline in `expected_response_at`. The owner token also guards completion and cleanup,
546+
so an expired request cannot modify a replacement reservation.
547+
548+
Reservation and execution share the request context's deadline. The response deadline adds a
549+
five-second persistence budget and uses Redis's clock for comparison. The executor reads the
550+
reservation and server time together, then selects the response:
551+
552+
| Reservation state | Result |
553+
|---|---|
554+
| New reservation | Run the operation callback. |
555+
| Different request fingerprint | Return 409 with `error_code: idempotency_request_mismatch`. |
556+
| Matching fingerprint with a cached response | Replay its status, body, and allowlisted headers. |
557+
| Matching fingerprint without a response, before its deadline | Return 409 with `error_code: idempotency_in_progress`. |
558+
| Matching fingerprint without a response, with an elapsed or unreadable deadline | Return 422 with `error_code: idempotency_outcome_unknown`. |
559+
| Redis failure, invalid retention configuration, or missing request deadline | Return 503. |
560+
561+
Retries against a reservation do not wait for or repeat the operation. The `error_code` field
562+
distinguishes the two conflict cases without requiring clients to parse messages. An unknown
563+
outcome means the API cannot establish the result, so retrying with a new key can duplicate work.
564+
A late response from the owner remains replayable while the reservation exists. Admission and
565+
retry errors do not replace the owner's cached result.
566+
567+
##### Response storage and delivery
568+
569+
The executor captures the handler's status and body, then attempts to persist them before
570+
delivery. Redis stores one JSON value containing the status, base64-encoded body, and allowlisted
571+
headers. Base64 preserves arbitrary response bytes; the separate serialized buffer lets Redis
572+
finish a write after the HTTP capture buffer has been released. The cache includes handler errors
573+
as well as successful responses, keeping retries attached to one attempt without inferring which
574+
errors permit safe re-execution.
575+
576+
The handler edits response headers in place while the executor buffers status and body writes.
577+
Only `Content-Type`, when present, enters the cache. Replay applies that header over the current
578+
request's headers, preserving fresh request IDs, rate limits, and cross-origin resource sharing
579+
(CORS) values. The capture pool reuses allocations up to 64 KiB and erases body contents before
580+
reuse because responses can contain credentials.
581+
582+
Persistence has a detached five-second wait budget and retains request context values for
583+
tracing. If it fails, the API logs the failure and delivers the handler's response: a caller should
584+
receive a known creation result even when Redis cannot store it. Subsequent retries can replay
585+
only a stored response; otherwise they receive the pending or unknown-outcome error.
586+
587+
##### Cancellation and retention
588+
589+
Before execution starts, cancellation or a reservation error triggers best-effort cleanup. The
590+
reservation worker waits for its Redis call to return before attempting deletion because socket
591+
I/O can outlive request cancellation. Cleanup gets its own five-second timeout and deletes only
592+
an unfinished record with the same owner token. Failed cleanup or an ambiguous Redis write can
593+
leave the reservation until expiry.
594+
595+
Once execution starts, cancellation, a panic, or failed persistence leaves the reservation in
596+
place. An operation can have side effects before writing any response, so releasing its key
597+
would allow duplicate work. The executor does not reconcile an unknown operation outcome.
598+
599+
The `api-idempotency-ttl-seconds` flag sets retention, defaulting to 86,400 seconds when
600+
unavailable. Configured values must be positive integers. The executor sets the time to live
601+
(TTL) at reservation; completion, retries, flag changes, and deleting the sandbox do not extend
602+
or remove it. Operators must choose retention longer than the operation window: expiry permits
603+
a new owner even if the first operation is still running. Redis eviction or data loss, a different
604+
key, and feature bypass also permit another execution. Redis capacity and access controls must
605+
account for cached response volume and the sandbox credentials those responses can contain.
606+
510607
### Sandbox traffic
511608

512609
```mermaid

‎packages/api/go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ require (
2828
github.com/e2b-dev/infra/packages/clickhouse v0.0.0
2929
github.com/e2b-dev/infra/packages/db v0.0.0
3030
github.com/e2b-dev/infra/packages/shared v0.0.0
31+
github.com/felixge/httpsnoop v1.1.0
3132
github.com/flowchartsman/retry v1.2.0
3233
github.com/getkin/kin-openapi v0.146.0
3334
github.com/gin-contrib/cors v1.7.6
@@ -153,7 +154,6 @@ require (
153154
github.com/exaring/otelpgx v0.11.1 // indirect
154155
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect
155156
github.com/fatih/color v1.18.0 // indirect
156-
github.com/felixge/httpsnoop v1.1.0 // indirect
157157
github.com/fsnotify/fsnotify v1.9.0 // indirect
158158
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
159159
github.com/gabriel-vasile/mimetype v1.4.15 // indirect

0 commit comments

Comments
 (0)