Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,30 @@ Two POSTs from the same caller, to the same route, with the same body, within 12

The 120s window is deliberately short. If you need true exactly-once across a longer window, pass an explicit `Idempotency-Key` (which gets the full 24h cache).

### Worked example: a double-click produces one resource

The simplest demonstration is two back-to-back `/db/new` calls from the same caller with the same body. Without `Idempotency-Key`, the fingerprint fallback collapses them:

```bash
# Two POSTs ~50ms apart — same caller, same body, no Idempotency-Key.
curl -sS -D /tmp/h1 -X POST https://api.instanode.dev/db/new \
-H 'Content-Type: application/json' -d '{"name":"my-db"}' > /tmp/r1.json &
curl -sS -D /tmp/h2 -X POST https://api.instanode.dev/db/new \
-H 'Content-Type: application/json' -d '{"name":"my-db"}' > /tmp/r2.json &
wait

grep -i "X-Idempotency-Source\|X-Idempotent-Replay" /tmp/h1 /tmp/h2
# /tmp/h1:X-Idempotency-Source: miss
# /tmp/h2:X-Idempotency-Source: fingerprint
# /tmp/h2:X-Idempotent-Replay: true

jq -r .token /tmp/r1.json /tmp/r2.json
# tok_3jX... (same token on both — one resource was created)
# tok_3jX...
```

The second call replays the first response verbatim. Only one Postgres database was provisioned. The same shape holds for `/deploy/new` (multipart, canonicalised by hashing the tarball + sorted form fields) and for `/api/v1/billing/checkout` (where the dashboard's client-side debounce, this fingerprint fallback, and the per-team `checkout_in_flight` SETNX guard layer to make sure a double-click never charges twice).

## Response headers

Every response from a create endpoint carries:
Expand Down
32 changes: 32 additions & 0 deletions docs/limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,35 @@ ceiling for self-serve upgrades today.
Limits are enforced at the Postgres user level (`CONNECTION LIMIT` on the
role) and via per-bucket storage quotas. Exceeding a limit returns a 402 with
an upgrade URL — your app keeps running, the next provision just fails.

## `POST /api/v1/billing/checkout` — concurrent-call dedup

`POST /api/v1/billing/checkout` is server-side deduplicated per team. A second
concurrent call for the same team — within a 60s window — gets a structured
409 instead of a second Razorpay subscription. This catches cross-tab clicks,
mobile double-taps, retried form submits, and agents that retry the endpoint
without coordination.

Response envelope:

```json
{
"ok": false,
"error": "checkout_in_flight",
"message": "A checkout is already being created for this team. Wait ~60s and retry, or visit /dashboard to find the existing pending subscription.",
"retry_after_seconds": 60,
"agent_action": "Tell the user a checkout is already being created. They should wait ~60 seconds and refresh — the existing checkout link will appear in the dashboard.",
"request_id": "..."
}
```

The `retry_after_seconds` field tells callers how long to wait. The TTL also
caps the worst case where the first caller crashes mid-flight — after 60s a
retry is allowed automatically. The standard `Idempotency-Key` header (see
`/docs/idempotency`) is honoured on this route too and provides a longer-window
guarantee — pass it on every retry of a logical checkout attempt.

If Redis is unavailable the dedup guard fails open (the call proceeds), with
a `WARN billing.checkout.dedup_setnx_failed_open` log line. A Redis brownout
must never block a paid upgrade — the idempotency middleware is the second
layer of defence.