@@ -507,6 +507,103 @@ gets a response, the sandbox is fully usable. Fresh creates are internally a *re
507507template's base snapshot (cold boots happen for filesystem-only templates and builds, or when
508508an 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
0 commit comments