Rate limits and errors
Errors
Section titled “Errors”Every refusal on every API is an RFC 9457 problem document, Content-Type: application/problem+json:
{ "type": "https://docs.masterdb.ai/errors/sort_required", "title": "A sort is required", "status": 400, "code": "sort_required", "detail": "…", "instance": "/v1/search", "errors": [ { "code": "…", "pointer": "/filter/all/1", "detail": "…" } ]}Branch on code. Codes are permanent: once published, a code is never renamed or reused for another meaning. title and detail are human text and may change. When a request or a record fails several checks, errors[] lists every reason at once, each with a JSON Pointer into what you sent. Error codes lists every code; each problem’s type links to its row there.
// Errors: every refusal is an RFC 9457 problem with a stable `code`. Branch on the code, never on the text.import assert from 'node:assert/strict';import { createPrivateKey } from 'node:crypto';import { readFileSync } from 'node:fs';import { SANDBOX, createEd25519Signer, createRetrievalClient } from '@masterdb/client';
const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_KEY_FILE as string)));const client = createRetrievalClient({ baseUrl: process.env.MASTERDB_API_URL ?? SANDBOX.retrieval, signer });
// No sort: refused with `sort_required`, never answered in an order MasterDB chose. Refusals are never billed.const noSort = await client.POST('/v1/search', { body: { collection: 'products', filter: { all: [{ country: 'US' }] } } as never });assert.equal(noSort.response.status, 400);assert.equal(noSort.error?.code, 'sort_required');console.log(noSort.error?.code, '-', noSort.error?.detail);
// No country, or two: `filter_required`. A field outside the collection's allow-list: `unknown_field`.const noCountry = await client.POST('/v1/search', { body: { collection: 'products', sort_by: 'published_at:desc' } as never });assert.equal(noCountry.error?.code, 'filter_required');const unknown = await client.POST('/v1/search', { body: { collection: 'products', filter: { all: [{ country: 'US' }, { colour: 'red' }] }, sort_by: 'published_at:desc' } as never,});assert.equal(unknown.error?.code, 'unknown_field');console.log(noCountry.error?.code, unknown.error?.code);
/** What to do with a refusal: fix the request, wait, or stop. */function nextStep(status: number, code: string, retryAfter: string | null): string { if (status === 429 || code === 'unavailable') return `retry after ${retryAfter ?? '1'} s`; if (code === 'allowance_exhausted') return 'stop: the prepaid allowance is spent'; if (status === 401) return 'check the key and the signature (clock, nonce, digest)'; return 'fix the request';}console.log(nextStep(noSort.response.status, noSort.error?.code ?? '', noSort.response.headers.get('retry-after')));"""Errors: every refusal is an RFC 9457 problem with a stable `code`. Branch on the code, never on the text."""
import os
import httpxfrom masterdb_signing import SANDBOX_API, MasterDBAuth, load_key
client = httpx.Client( base_url=os.environ.get("MASTERDB_API_URL", SANDBOX_API), auth=MasterDBAuth(load_key(os.environ["MASTERDB_KEY_FILE"])),)
# No sort: refused with `sort_required`, never answered in an order MasterDB chose. Refusals are never billed.no_sort = client.post("/v1/search", json={"collection": "products", "filter": {"all": [{"country": "US"}]}})problem = no_sort.json()assert no_sort.status_code == 400 and problem["code"] == "sort_required"assert no_sort.headers["content-type"].startswith("application/problem+json")print(problem["code"], "-", problem.get("detail"))
# No country, or two: `filter_required`. A field outside the collection's allow-list: `unknown_field`.no_country = client.post("/v1/search", json={"collection": "products", "sort_by": "published_at:desc"})assert no_country.json()["code"] == "filter_required"unknown = client.post( "/v1/search", json={"collection": "products", "filter": {"all": [{"country": "US"}, {"colour": "red"}]}, "sort_by": "published_at:desc"},)assert unknown.json()["code"] == "unknown_field"print(no_country.json()["code"], unknown.json()["code"])
def next_step(res: httpx.Response) -> str: """What to do with a refusal: fix the request, wait, or stop.""" code = res.json().get("code", "") if res.status_code == 429 or code == "unavailable": return f"retry after {res.headers.get('retry-after', '1')} s" if code == "allowance_exhausted": return "stop: the prepaid allowance is spent" if res.status_code == 401: return "check the key and the signature (clock, nonce, digest)" return "fix the request"
print(next_step(no_sort))| Status | What to do |
|---|---|
400, 422 |
Fix the request. Never retry unchanged: the answer will not change. |
401 |
Check the key and the signature: a registered and unrevoked key, a synchronised clock, a fresh nonce, a Content-Digest over the exact bytes sent (Signing requests). |
402 allowance_exhausted |
Stop: your prepaid allowance is spent (Usage and billing). |
404 |
The record is not available to you. A blocked, withdrawn and never-existing record are the same answer; do not retry. |
429 rate_limited |
Wait the Retry-After seconds, sign again, retry. |
503 unavailable |
Wait the Retry-After seconds and retry. Not billed. |
500 internal |
MasterDB failed. Retry with backoff; not billed. |
A retry is a new request: sign it again, with a new nonce.
Rate limits
Section titled “Rate limits”Two layers, both stated. The figures are the same for every AI company and every key, in the sandbox and in production: there is one set of limits, and no company has its own.
-
At the edge, per client IP address: 6,000 requests a minute (100 a second) across the public hosts (
api,verifyandmcp), behind a web application firewall. The edge limit sits above one key’s limit below, so it never refuses what a single key may send. -
Per key (not per company), by the retrieval service, on every retrieval request:
Limit Request rate 50 requests a second Burst 100 requests Query cost budget 6,000 cost units a minute The ads endpoints have their own per-key limit of 50 a second with a burst of 100. A request over either limit is
429rate_limitedwithRetry-After, in whole seconds and at least 1.
The cost budget is measured in the index time your searches take: a search costs the milliseconds the index spent on it, rounded up, and at least one unit, so a query that scans everything costs its sender more of the budget than a narrow one. The budget refills continuously. It is checked before a search and charged after it, so a single expensive search can take the balance below zero, and the next searches wait until it is positive again.
Plan on the figures above for one key, and back off when you are told to. Neither layer is the billing meter; the receipts are.
Idempotency
Section titled “Idempotency”Every POST that creates something takes an Idempotency-Key header (a fresh UUID each time); a retry with the same key answers what the first attempt answered. Searches and fetches create nothing and need none.