Errors and retries
Every status and error code of the MIRA FIVE ingest API, when to retry, how batch ids make retries safe, and the rate limits.
The ingest API answers errors as JSON with a machine-readable code. Decide what to do by the status: retry 408, 429, 5xx and network failures with the byte-identical body, fix everything else. A 202 is final, even when it reports dropped events.
Error body
{
"code": "validation_failed",
"detail": "The batch does not match the event schema.",
"errors": [{ "path": "events.0.name", "message": "The events.0.name field must be a name without surrounding whitespace." }]
}| Field | Type | Description |
|---|---|---|
code | string | What went wrong, ^[a-z][a-z0-9_]*$. See Error codes. |
detail | string | A sentence for humans. Do not parse it. |
errors | array | validation_failed only: up to 10 { "path", "message" } objects. path names the field (mode, events.3.id, context.screen.0), never its value. |
Some answers are produced before a request reaches the API and carry { "message": "…" } instead of a code: a path that does not exist or whose key does not look like a key (404), a body the web server refuses as too large (413), and maintenance (503, with Retry-After). Every answer, errors included, carries the CORS headers, so a browser can read it.
Status codes
| Status | Meaning | Retry |
|---|---|---|
200 | Flag document or lookup answered. | — |
202 | Batch finished. See the receipt. | Never |
304 | Server flag document unchanged since your ETag. | — |
400 | The body is not JSON, or not a valid batch. | No |
401 | The key is missing, unknown, revoked or archived. | No |
403 | The key is in the wrong place, exposed, or used from an origin the source does not allow. | No |
404 | Unknown path, or feature flags are not available for the key. | No |
408 | Request timeout (from a proxy). | Yes |
413 | The body is over 1 MiB. | No: split the batch |
422 | A segment lookup body the server cannot read. | No |
429 | Rate limited. | Yes, after Retry-After |
5xx | The server could not finish, e.g. 503 sink_unavailable. | Yes |
| no answer | Network error or timeout. | Yes |
Error codes
| Code | Status | Meaning | Fix | Retry |
|---|---|---|---|---|
invalid_json | 400 | The body does not parse as JSON. detail says why, e.g. Single unpaired UTF-16 surrogate in unicode escape. | Encode well-formed UTF-8. Do not cut strings in the middle of a surrogate pair. | No |
validation_failed | 400 | The batch breaks a rule of Send events. errors names the fields. | Fix or drop the events named in errors, then send the rest as a new batch. | No |
collection_mode_not_allowed | 400 | A "full" batch sent to a consentless source. | Send "mode": "consentless" without identifiers, or switch the source to full mode. | No |
unauthorized | 401 | Missing, unknown, revoked or archived key, or a key of an archived source or project. | Check the key on Keys. | No |
website_key_as_bearer | 403 | A website key in the Authorization header. | Put the website key in the path, or use the secret key of a server source. | No |
secret_key_in_path | 403 | A secret key in a URL path. Marked exposed when a browser sent it. | Send the secret key as Authorization: Bearer. | No |
secret_key_exposed | 403 | Flag endpoints only: a secret key arrived with an Origin or Sec-Fetch-Site header. | Rotate the key and keep it on the server. See Exposed secret keys. | No |
origin_not_allowed | 403 | The Origin header is not one of the source's allowed origins. | Add the site's origin to the source. | No |
lookup_not_allowed | 403 | POST /v1/flags/{websiteKey} on a consentless source. | Use GET /v1/flags/{websiteKey}. | No |
not_found | 404 | Feature flags are not available for this key's organization. | — | No |
payload_too_large | 413 | The body is over 1,048,576 bytes. | Split the events into smaller batches, each with its own batch id. | No |
invalid_units | 422 | units is not a list, or a unit has no string userId or anonymousId. | Send { "units": [{ "userId": "…" }] }. | No |
too_many_units | 422 | More than 100 units in one segment lookup. | Split the lookup. | No |
rate_limited | 429 | A rate limit is spent. | Wait Retry-After seconds. | Yes |
sink_unavailable | 503 | The batch could not be stored completely. | Resend it unchanged; the batch id keeps it from being stored twice. | Yes |
Error codes may be added within v1. Treat an unknown code by its status.
Retry policy
Retry on 408, 429, any 5xx, network errors and timeouts. Do not retry any other 4xx, and never retry a 202.
- Resend the byte-identical body, with the same batch id. Serialise the batch once and keep the bytes.
- Back off with full jitter: before retry
n(0-based), wait a random time between 0 andmin(cap, base × 2ⁿ). The Node SDK uses a base of 250 ms and a cap of 4 seconds. - Honour
Retry-Afterwhen a429or503carries it: wait that many seconds instead of the backoff. Bound the wait to what your caller can afford. - Give up after a few attempts and report the failure. A batch that was never answered with
202may or may not have been stored; resending it later under the same id is safe.
A browser that flushes on page hide with sendBeacon cannot read the answer. That is accepted: it cannot retry either.
Idempotency
The batch id is the idempotency key. The server stores a batch id once per project: sending the same batch again within a day is counted and stored once, and answered with the same receipt. The server keys on the id, not the bytes, so a different body under a used id is not stored. Always give a new batch a new id, and a retry the old one.
A client that accepts its own idempotency key from the caller (an order number, a webhook event id) derives the batch id from it, so every SDK agrees on the id:
batch = UUIDv8( SHA-256( "mirafive:batch:" ‖ key ) )- Encode
"mirafive:batch:"followed by the key as UTF-8. The key must not be empty. - Hash with SHA-256 and take the first 16 bytes.
- Set the version:
bytes[6] = bytes[6] & 0x0f | 0x80. - Set the variant:
bytes[8] = bytes[8] & 0x3f | 0x80. - Format as a lower-case UUID,
8-4-4-4-12hex digits.
Worked example for the key order-981:
| Step | Value |
|---|---|
SHA-256 of mirafive:batch:order-981 | 274e05f04dd71db9656387ea5c891487503b9df472193d0666ced68d06a20404 |
| first 16 bytes | 27 4e 05 f0 4d d7 1d b9 65 63 87 ea 5c 89 14 87 |
bytes[6]: 0x1d & 0x0f | 0x80 | 0x8d |
bytes[8]: 0x65 & 0x3f | 0x80 | 0xa5 |
| batch id | 274e05f0-4dd7-8db9-a563-87ea5c891487 |
import hashlib
import uuid
def batch_id(key: str) -> str:
if key == "":
raise ValueError("the idempotency key must not be empty")
b = bytearray(hashlib.sha256(("mirafive:batch:" + key).encode("utf-8")).digest()[:16])
b[6] = b[6] & 0x0F | 0x80
b[8] = b[8] & 0x3F | 0x80
return str(uuid.UUID(bytes=bytes(b)))
print(batch_id("order-981")) # 274e05f0-4dd7-8db9-a563-87ea5c891487More test vectors from the protocol fixtures (batch-id.cases.json):
| Key | Batch id |
|---|---|
a | 08121d76-bf7c-8abc-b3f2-b4d829d528e8 |
Order-981 | 33054841-d9c7-8916-8b1c-a93ea2bc8d83 |
order-981 (trailing space) | 98885f1a-aa10-84bb-8e3a-b5fc0ef59b10 |
stripe:evt_1PqL4mK2eZvKYlo2C0nX3Yz | 659d3cac-a353-8d7a-bedd-c1cbe51e4089 |
müller | 014e1e19-76ed-83d4-9c73-9241eb5b2e2c |
🚀 launch | dd5a0288-6ddc-80cb-9e1c-06e197e55ff9 |
Keys are used exactly as given: case, whitespace and Unicode normalisation all change the id.
Rate limits
Limits count per client address (an IPv4 address or an IPv6 /64) and per key. Website keys are public, so their budgets count per key and client network: a copied key only drains its own bucket. The current limits:
| Endpoint | Counted | Limit |
|---|---|---|
POST /v1/batch…, before the key is checked | requests per address | 100 per second |
POST /v1/batch/{websiteKey} | events per key and client network | 6,000 per minute |
POST /v1/batch | events per key | 120,000 per minute |
/v1/flags…, before the key is checked | requests per address | 200 per second |
GET /v1/flags/{websiteKey} | requests per key and client network | 100 per second |
POST /v1/flags/{websiteKey} | requests per key and client network | 20 per second |
GET /v1/flags | requests per key | 6,000 per minute |
POST /v1/flags/segments | units per key | 6,000 per minute |
Batches are charged per event; a batch the server cannot read costs one. A limit refuses only once its bucket is spent, so a batch larger than the remaining budget still passes once. Over a limit, the answer is 429 rate_limited with Retry-After in seconds. Limits can change: rely on 429 and Retry-After, not on these numbers.
Exposed secret keys
A secret key is exposed once it has been seen from a browser: any request carrying an Origin or Sec-Fetch-Site header, which browsers add and server runtimes and curl do not. That also happens when the key is sent in a URL path from a browser.
| Endpoint | Exposed key |
|---|---|
POST /v1/batch | Still accepted, so a leak shows up in MIRA FIVE rather than as lost data. |
GET /v1/flags, POST /v1/flags/segments | Refused with 403 secret_key_exposed. |
An exposed key stays exposed. Create a new secret key for the server source, deploy it, and revoke the old one. See Keys.
Then find where the key reached a browser: a public env var prefix (NEXT_PUBLIC_, VITE_, PUBLIC_, NUXT_PUBLIC_), a client bundle, or a server call made from a page.