ConceptsRate limits and retries
Rate limits and retries
What the API answers when it throttles a client, how to retry with Retry-After and back-off, and where the monthly API call limits of each plan are stated.
Answer
A throttled request answers HTTP 429 with code 182 and, when the limit computes one, a Retry-After header in seconds: wait that long, then retry. Without the header, back off exponentially. No per-minute limit is published. Monthly API call limits depend on your plan; see plans and limits. Never retry a 401.
An integration that sends many requests will at some point be asked to slow down. Build the retry in from the start: it costs a few lines, and it is the difference between a slow sync and a failed one.
How throttling looks
A throttled request is refused with HTTP 429, code 182 ERROR_TOO_MANY_REQUESTS. In REST the
body is the usual error envelope. In GraphQL it is not an
errors list but a short body with status, message and errorcode, the same shape as a
GraphQL 401. When the limit computes a wait, a Retry-After header gives it in whole seconds.
No per-minute number is published.
Failed sign-ins are throttled on their own. After repeated failed sign-ins the credentials are
locked for a while: every request answers 429, even with the right password, and without
Retry-After. Stop at the first HTTP 401 and check the credentials
(authentication): retrying a 401 only adds failed sign-ins.
Retry after a 429
Every request in this helper goes through one function. On HTTP 429 it waits for Retry-After,
or 5 seconds when the header is missing, and tries again, up to three attempts; any other error
is raised with its code:
# call METHOD PATH [BODY]: one REST request; prints the body. On HTTP 429 it waits as long as
# Retry-After says (5 seconds when the header is missing) and tries again, three attempts in
# all. On any other error it prints "HTTP <status>, code <code> <name>: <message>" and the
# body, sets ERROR_CODE to the code and returns 1.
call() {
local attempt headers response status delay
local args=(-X "$1" "$COEVERA_BASE$2" -u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
if [ $# -ge 3 ]; then args+=(-H "Content-Type: application/json" -d "$3"); fi
headers=$(mktemp)
for attempt in 1 2 3; do
response=$(curl -sS -D "$headers" -w '\n%{http_code}' "${args[@]}")
status=${response##*$'\n'}
response=${response%$'\n'*}
if [ "$status" = 429 ] && [ "$attempt" -lt 3 ]; then
delay=$(sed -n 's/^[Rr]etry-[Aa]fter: *\([0-9][0-9]*\).*/\1/p' "$headers")
sleep "${delay:-5}"
continue
fi
rm -f "$headers"
if [ "$status" -ge 400 ]; then
# The message is for people; branch on the numeric code.
jq -r --arg status "$status" \
'"HTTP \($status), code \(.code) \(.name): \(.message | sub("^\\s+"; "") | sub("\\s+$"; ""))"' <<<"$response"
echo "$response"
ERROR_CODE=$(jq -r '.code' <<<"$response")
return 1
fi
echo "$response"
return 0
done
}// An error the API answered: the HTTP status, and the code, name and message of its body.
class ApiError extends Error {
constructor(
readonly status: number,
readonly code: number,
readonly errorName: string,
message: string,
readonly body: unknown,
) {
super(`HTTP ${status}, code ${code} ${errorName}: ${message}`);
}
}
const sleep = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// call: one REST request. On HTTP 429 it waits as long as Retry-After says (5 seconds when the
// header is missing) and tries again, three attempts in all. Any other error is an ApiError.
async function call(path: string, init: { method?: string; body?: unknown } = {}) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`${base}${path}`, {
method: init.method ?? "GET",
headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
if (res.status === 429 && attempt < 3) {
await sleep(Number(res.headers.get("Retry-After")) || 5);
continue;
}
const body = await res.json();
// The message is for people; branch on the numeric code.
if (!res.ok) throw new ApiError(res.status, body.code, body.name, String(body.message).trim(), body);
return body;
}
}class ApiError(Exception):
"""An error the API answered: the HTTP status, and the code, name and message of its body."""
def __init__(self, status, body):
self.status, self.body = status, body
self.code, self.name = body.get("code"), body.get("name")
message = str(body.get("message")).strip()
super().__init__(f"HTTP {status}, code {self.code} {self.name}: {message}")
def call(method, path, **kwargs):
"""One REST request. On HTTP 429 it waits as long as Retry-After says (5 seconds when the
header is missing) and tries again, three attempts in all. Any other error is an ApiError."""
for attempt in range(1, 4):
response = session.request(method, f"{BASE}{path}", timeout=30, **kwargs)
if response.status_code == 429 and attempt < 3:
time.sleep(float(response.headers.get("Retry-After") or 5))
continue
body = response.json()
# The message is for people; branch on the numeric code.
if not response.ok:
raise ApiError(response.status_code, body)
return body# call REQUEST: POST one GraphQL request (a JSON body); prints the data. On HTTP 429 it waits as
# long as Retry-After says (5 seconds when the header is missing) and tries again, three
# attempts in all. On any error it prints "HTTP <status>, code <code> <name>: <message>" and
# the error, sets ERROR_CODE to the api_error code (null when there is none) and returns 1.
call() {
local attempt headers response status delay
headers=$(mktemp)
for attempt in 1 2 3; do
response=$(curl -sS -D "$headers" -w '\n%{http_code}' "$COEVERA_GRAPHQL" \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
-H "Content-Type: application/json" \
-d "$1")
status=${response##*$'\n'}
response=${response%$'\n'*}
if [ "$status" = 429 ] && [ "$attempt" -lt 3 ]; then
delay=$(sed -n 's/^[Rr]etry-[Aa]fter: *\([0-9][0-9]*\).*/\1/p' "$headers")
sleep "${delay:-5}"
continue
fi
rm -f "$headers"
if [ "$status" != 200 ] || jq -e '.errors' <<<"$response" >/dev/null; then
# The message is for people; branch on the numeric code.
jq -r --arg status "$status" '.errors[0]
| "HTTP \($status), code \(.api_error.code) \(.api_error.name // ""): \(.message | sub("^\\s+"; "") | sub("\\s+$"; ""))"' <<<"$response"
jq '.errors[0]' <<<"$response"
ERROR_CODE=$(jq -r '.errors[0].api_error.code' <<<"$response")
return 1
fi
jq '.data' <<<"$response"
return 0
done
}// An error the API answered: the HTTP status, and the code and name of api_error when the
// error has one (a document the schema refuses has none).
class ApiError extends Error {
constructor(
readonly status: number,
readonly code: number | undefined,
readonly errorName: string | undefined,
message: string,
readonly error: unknown,
) {
super(`HTTP ${status}, code ${code ?? "none"} ${errorName ?? ""}: ${message}`);
}
}
const sleep = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// call: one GraphQL request. On HTTP 429 it waits as long as Retry-After says (5 seconds when
// the header is missing) and tries again, three attempts in all. Any error is an ApiError.
async function call(query: string, variables: object = {}) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(endpoint, {
method: "POST",
headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (res.status === 429 && attempt < 3) {
await sleep(Number(res.headers.get("Retry-After")) || 5);
continue;
}
const body = await res.json();
const error = body.errors?.[0];
// The message is for people; branch on the numeric code.
if (!res.ok || error) {
throw new ApiError(res.status, error?.api_error?.code, error?.api_error?.name, String(error?.message).trim(), error);
}
return body.data;
}
}class ApiError(Exception):
"""An error the API answered: the HTTP status, and the code and name of api_error when the
error has one (a document the schema refuses has none)."""
def __init__(self, status, code, name, message, error):
super().__init__(f"HTTP {status}, code {code} {name}: {message}")
self.status, self.code, self.name, self.error = status, code, name, error
def call(query, variables=None):
"""One GraphQL request. On HTTP 429 it waits as long as Retry-After says (5 seconds when the
header is missing) and tries again, three attempts in all. Any error is an ApiError."""
for attempt in range(1, 4):
response = session.post(ENDPOINT, json={"query": query, "variables": variables or {}}, timeout=30)
if response.status_code == 429 and attempt < 3:
time.sleep(float(response.headers.get("Retry-After") or 5))
continue
body = response.json()
error = (body.get("errors") or [None])[0]
# The message is for people; branch on the numeric code.
if not response.ok or error:
api_error = (error or {}).get("api_error") or {}
message = str((error or {}).get("message")).strip()
raise ApiError(response.status_code, api_error.get("code"), api_error.get("name"), message, error)
return body["data"]In your own client:
- Retry HTTP 429 after
Retry-After. When the header is missing, back off exponentially, such as 1, 2, 4 and 8 seconds, with a cap. - Retry HTTP 500 and network errors with back-off too, but only reads, or writes you can check: a write that failed with 500 may have been applied. Read the record back before you send it again.
- Never retry other 4xx answers unchanged. A 400 or 422 fails the same way the next time; fix the request.
- Spread large jobs out instead of sending them in parallel, and use batches of up to 100 records to make fewer requests.
GraphQL operation size
One operation on the public GraphQL endpoint holds at most 200 mutations and at most 500
aliased fields; a fragment's aliases count at every place it is spread. A larger operation is
refused before any of it runs, with HTTP 400 and only errors[].message, such as
Too many mutations in one operation: 201 (limit 200). Split them into several requests. Split
the work into several requests, or write many records with one bulkUpdate… mutation
(batch operations).
Plan limits
The number of API calls a space may make each month depends on its Coevera plan: see plans and limits, which links the pricing page. The MCP server has limits of its own: see MCP limits and errors.
Limits
| Limit | Value | Details |
|---|---|---|
| Requests per minute | Not published | A throttled request answers HTTP 429, with Retry-After when the limit computes one |
| API calls per month | Per plan | See plans and limits |
| Failed sign-ins | Throttled | Stop at the first 401; a locked sign-in answers 429 without Retry-After |
| GraphQL operation | 200 mutations, 500 aliased fields | A larger operation answers HTTP 400 |