MIRA FIVE

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

400 Bad Request
{
  "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." }]
}
FieldTypeDescription
codestringWhat went wrong, ^[a-z][a-z0-9_]*$. See Error codes.
detailstringA sentence for humans. Do not parse it.
errorsarrayvalidation_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

StatusMeaningRetry
200Flag document or lookup answered.—
202Batch finished. See the receipt.Never
304Server flag document unchanged since your ETag.—
400The body is not JSON, or not a valid batch.No
401The key is missing, unknown, revoked or archived.No
403The key is in the wrong place, exposed, or used from an origin the source does not allow.No
404Unknown path, or feature flags are not available for the key.No
408Request timeout (from a proxy).Yes
413The body is over 1 MiB.No: split the batch
422A segment lookup body the server cannot read.No
429Rate limited.Yes, after Retry-After
5xxThe server could not finish, e.g. 503 sink_unavailable.Yes
no answerNetwork error or timeout.Yes

Error codes

CodeStatusMeaningFixRetry
invalid_json400The 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_failed400The 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_allowed400A "full" batch sent to a consentless source.Send "mode": "consentless" without identifiers, or switch the source to full mode.No
unauthorized401Missing, unknown, revoked or archived key, or a key of an archived source or project.Check the key on Keys.No
website_key_as_bearer403A 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_path403A secret key in a URL path. Marked exposed when a browser sent it.Send the secret key as Authorization: Bearer.No
secret_key_exposed403Flag 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_allowed403The Origin header is not one of the source's allowed origins.Add the site's origin to the source.No
lookup_not_allowed403POST /v1/flags/{websiteKey} on a consentless source.Use GET /v1/flags/{websiteKey}.No
not_found404Feature flags are not available for this key's organization.—No
payload_too_large413The body is over 1,048,576 bytes.Split the events into smaller batches, each with its own batch id.No
invalid_units422units is not a list, or a unit has no string userId or anonymousId.Send { "units": [{ "userId": "…" }] }.No
too_many_units422More than 100 units in one segment lookup.Split the lookup.No
rate_limited429A rate limit is spent.Wait Retry-After seconds.Yes
sink_unavailable503The 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 and min(cap, base × 2ⁿ). The Node SDK uses a base of 250 ms and a cap of 4 seconds.
  • Honour Retry-After when a 429 or 503 carries 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 202 may 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 ) )
  1. Encode "mirafive:batch:" followed by the key as UTF-8. The key must not be empty.
  2. Hash with SHA-256 and take the first 16 bytes.
  3. Set the version: bytes[6] = bytes[6] & 0x0f | 0x80.
  4. Set the variant: bytes[8] = bytes[8] & 0x3f | 0x80.
  5. Format as a lower-case UUID, 8-4-4-4-12 hex digits.

Worked example for the key order-981:

StepValue
SHA-256 of mirafive:batch:order-981274e05f04dd71db9656387ea5c891487503b9df472193d0666ced68d06a20404
first 16 bytes27 4e 05 f0 4d d7 1d b9 65 63 87 ea 5c 89 14 87
bytes[6]: 0x1d & 0x0f | 0x800x8d
bytes[8]: 0x65 & 0x3f | 0x800xa5
batch id274e05f0-4dd7-8db9-a563-87ea5c891487
batch_id.py
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-87ea5c891487

More test vectors from the protocol fixtures (batch-id.cases.json):

KeyBatch id
a08121d76-bf7c-8abc-b3f2-b4d829d528e8
Order-98133054841-d9c7-8916-8b1c-a93ea2bc8d83
order-981 (trailing space)98885f1a-aa10-84bb-8e3a-b5fc0ef59b10
stripe:evt_1PqL4mK2eZvKYlo2C0nX3Yz659d3cac-a353-8d7a-bedd-c1cbe51e4089
müller014e1e19-76ed-83d4-9c73-9241eb5b2e2c
🚀 launchdd5a0288-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:

EndpointCountedLimit
POST /v1/batch…, before the key is checkedrequests per address100 per second
POST /v1/batch/{websiteKey}events per key and client network6,000 per minute
POST /v1/batchevents per key120,000 per minute
/v1/flags…, before the key is checkedrequests per address200 per second
GET /v1/flags/{websiteKey}requests per key and client network100 per second
POST /v1/flags/{websiteKey}requests per key and client network20 per second
GET /v1/flagsrequests per key6,000 per minute
POST /v1/flags/segmentsunits per key6,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.

EndpointExposed key
POST /v1/batchStill accepted, so a leak shows up in MIRA FIVE rather than as lost data.
GET /v1/flags, POST /v1/flags/segmentsRefused 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.

On this page