- Guides
- Idempotency and conditional writes
Guides
Idempotency and conditional writes
Retry a create safely with an Idempotency-Key, avoid lost updates with ETag and If-Match, and make your receiver safe to call twice.
Networks fail partway through requests. This guide covers three ways to stay safe when that happens:
- Idempotency keys make retrying a create safe.
- Conditional writes stop you overwriting a change someone else made.
- Receiver-side idempotency makes it safe for your receiver to get the same callback twice.
Idempotency keys
Send an Idempotency-Key header, and retrying the request cannot create a second resource.
Two operations read the header:
| Operation | Header |
|---|---|
schedules.create | optional |
occurrences.redeliver | required |
No other operation reads it. Sending one elsewhere changes nothing.
Choosing a key
- 16 to 255 characters, all printable ASCII (space through tilde).
- Generate a new key for each schedule you mean to create. Reuse a key only to retry that same request.
- Keys are scoped to your organization, project, environment, and the operation, so another project can use the same key without conflict.
- Greatwait keeps a key’s receipt for 24 hours. Do not count on a retry being recognised after that.
What a retry returns
| You send | Greatwait answers |
|---|---|
| A new key | Creates the resource. |
| The same key with the same request | Creates nothing. Returns the resource that key created, with the response header Idempotency-Replayed: true. |
| The same key with a different request | Creates nothing. Refuses with 409 and code idempotency_key_reused. |
Two requests count as the same if they differ only in JSON key order or insignificant whitespace.
A replay returns the resource as it is now, not a copy of the first response. If the schedule was paused in the meantime, the replay shows it paused. Idempotency-Replayed is either true or absent. It is never false.
Example
This creates a one-shot, then repeats the identical request. It expects GREATWAIT_API_KEY and GREATWAIT_ENDPOINTID to be set in your environment already. curl reads the API key from the environment itself (curl 8.3.0 or later), so the key never appears on the command line.
GREATWAIT_API_URL="https://api.greatwait.dev"
GREATWAIT_IDEMPOTENCY_KEY="first-value-$(date +%s)-$"
BODY='{"delaySeconds":30,"endpoint":"'"$GREATWAIT_ENDPOINTID"'"}'
for try in 1 2; do
curl -sS -D - -o /dev/null -X POST "$GREATWAIT_API_URL/v1/schedules" \
--variable %GREATWAIT_API_KEY \
--expand-header "Authorization: Bearer {{GREATWAIT_API_KEY}}" \
-H "Idempotency-Key: $GREATWAIT_IDEMPOTENCY_KEY" \
-H 'Content-Type: application/json' \
--data-binary "$BODY"
done
The first response is 201. The second carries Idempotency-Replayed: true and describes the same schedule.
In JavaScript, the requestDetailed method of the @greatwait/api-client client returns response headers alongside the body, which is how you read Idempotency-Replayed.
When to retry
Retry schedules.create with the same key when the request failed without an answer, or was refused with rate_limited or service_unavailable. For any other refusal, fix the request first. A changed body needs a new key.
Conditional writes
Schedules and endpoints carry a version. Reading one returns it as a strong ETag header, such as ETag: "7". Every successful write returns the new ETag.
To write only if nothing has changed since you read, send that value back in If-Match:
| Operation | If-Match |
|---|---|
schedules.replace | optional |
schedules.pause | optional |
schedules.resume | optional |
schedules.cancel | optional |
schedules.archive | optional |
endpoints.update | optional |
endpoints.rotateSigningKey | optional |
endpoints.acknowledgeSigningKeyRotation | required |
- If the version still matches, the write goes ahead.
- If someone else changed the resource first, the write is refused with 409 and
codeversion_conflict. Nothing is written. Read the resource again, decide whether your change still makes sense, and retry with the newETag. - Only strong tags are accepted. A weak tag such as
W/"7", or*, is refused withinvalid_request. - A write without
If-Matchis unconditional: the last writer wins.
A different 409, state_conflict, means the resource is in a state where the operation cannot apply at all, for example resuming a cancelled schedule. Re-reading will not help.
# Read the schedule and keep its ETag.
ETAG=$(curl -sS -D - -o /dev/null "$GREATWAIT_API_URL/v1/schedules/$GREATWAIT_SCHEDULEID" \
--variable %GREATWAIT_API_KEY \
--expand-header "Authorization: Bearer {{GREATWAIT_API_KEY}}" \
| sed -n 's/^[Ee][Tt][Aa][Gg]: *//p' | tr -d '\r')
# Pause it only if nobody changed it since.
curl -sS -X POST "$GREATWAIT_API_URL/v1/schedules/$GREATWAIT_SCHEDULEID/pause" \
--variable %GREATWAIT_API_KEY \
--expand-header "Authorization: Bearer {{GREATWAIT_API_KEY}}" \
-H "If-Match: $ETAG"
Receiver-side idempotency
Callback delivery is at least once. Your receiver can get the same occurrence more than once: after a timeout, a dropped connection, or a retry following an answer Greatwait never received. Each of those deliveries has a new delivery id and the same occurrence id.
So your receiver should:
- ignore a delivery id it has already handled,
- run the work for an occurrence at most once for automatic deliveries,
- run it again when a person orders a redelivery.
Verifying callbacks shows how, and @greatwait/api-client does all three for you.