Errors¶
Almost every error the AppGantry API returns uses one flat envelope. This page is the catalogue — and it names the exception, because a client that assumes the envelope everywhere will fail to parse two of the reads it is most likely to try early.
Canonical page
This page is the canonical owner of the error taxonomy: codes, statuses, billing gates, and limits. Other pages link here rather than restating them. API conventions owns the upload contract and retry semantics.
The envelope¶
| Field | Notes |
|---|---|
error |
Machine-readable code. Part of the API contract: match on this. |
message |
Human-readable, safe to show in a UI, subject to rewording. Never match on it. |
details |
Present on the full envelope, defaulting to {}. Absent on the two bare bodies described below. |
Renaming an error code is a breaking change. Adding a new one is not,
so treat an unrecognised code as a generic failure of its HTTP status
class rather than crashing.
This envelope is published in the schema as ErrorResponse, and the
operations that can return it advertise it per status code.
Request validation uses the same envelope. A missing field, a wrong
type, or a malformed UUID in the path answers 400 with
error: "validation_error" and the failing fields under
details.errors — see Validation failures. It
is published as ValidationErrorResponse, which is the envelope above
with details.errors filled in.
The two exceptions¶
Read error defensively. Two responses are not the full envelope:
| Exception | Shape | Where |
|---|---|---|
Not-configured 404s |
A bare {"error": "…"} — error only, no message, no details |
GET .../organizations/{id}/sso and GET .../organizations/{id}/byosa/onboarding-request |
The bare 404 is deliberate. Those two reads answer the same 404
whether the feature is simply not configured or the organization does
not exist / you cannot see it — the second case answers the full
envelope, and the two are indistinguishable on purpose so the routes
cannot be used to enumerate organizations. So on those two routes:
- read
errorwith a default rather than indexingdetails; - do not infer that an organization exists from the shape of its 404.
Everything else uses the full envelope, including the validation failures described next — they are the ones you will hit first.
Validation failures¶
A request that fails schema validation — a missing required field, a
wrong type, a malformed UUID in the path, a query parameter that isn't a
valid date — answers 400 Bad Request with the flat envelope and
error: "validation_error":
{
"error": "validation_error",
"message": "Field required",
"details": {
"errors": [
{
"type": "missing",
"loc": ["body", "version_name"],
"msg": "Field required"
}
]
}
}
details.errors carries one entry per failing field, and each entry
carries exactly these three keys:
| Field | Meaning |
|---|---|
type |
Machine-readable validation failure kind, for example missing, string_type, uuid_parsing. |
loc |
Path to the offending value: the first element is the part of the request (body, query, path, header), the rest addresses the field. |
msg |
Human-readable explanation of that one failure. |
message repeats the first entry's msg, so a client with nowhere to
render a list still has something to show.
The value you sent is never echoed back. There is no input field
and no ctx field: a rejected password or token would otherwise land in
the response body and in every log downstream of it, so the response is
restricted to the three keys above. Do not code against a fourth.
Handle it with the same parse as every other error:
body = response.json()
code = body.get("error") # flat or bare envelope
if code == "validation_error":
problems = body["details"]["errors"] # list of field failures
This is the ValidationErrorResponse shape in the schema, and it is the
most widely declared error response there: any operation that takes a
path parameter, a query parameter, or a body can return it.
A validation_error is never fixed by retrying the same request. Fix
the request.
Codes by status¶
400 Bad Request¶
error |
Meaning |
|---|---|
invalid_input |
A field was missing, malformed, or out of range. |
validation_error |
The request failed schema validation: a missing field, a wrong type, a malformed identifier. details.errors names the offending fields — see Validation failures. |
operation_not_allowed |
The request is well-formed but not legal in the resource's current state — for example rolling back a channel with nothing to roll back to. |
quota_exceeded |
A structural count limit would be exceeded: too many organizations, projects, or passkeys. |
validation_error is the most widely declared error response in the
schema: any operation that takes a path parameter, a query parameter, or
a body can return it.
quota_exceeded is not a billing error, and it is not a size error. It
means a structural count limit, so retrying won't help until you delete
something. Something that is too big rather than too many is a
413, not a 400. See
Feature limits.
401 Unauthorized¶
error |
Meaning |
|---|---|
unauthorized |
No credential, an unrecognised credential, or an expired one. |
Refresh your access token and retry once. If a refresh token is rejected, the whole session is gone: sign in again. Refresh tokens are single-use, and presenting one twice revokes the entire session family as a theft-detection measure.
403 Forbidden¶
error |
Meaning |
|---|---|
permission_denied |
The credential is valid but not allowed to do this — often a token used on a surface that requires an interactive sign-in, or one whose scope doesn't cover the operation. |
insufficient_role |
The caller's role in the organization or project is too low. |
email_not_verified |
The account exists but hasn't verified its email; state-changing calls are refused until it does. |
mfa_enrollment_required |
An organization the caller belongs to requires multi-factor authentication and the caller has no factor enrolled. |
A 403 is never fixed by retrying. See Authorization.
404 Not Found¶
error |
Meaning |
|---|---|
not_found |
The resource doesn't exist, or the caller isn't allowed to know that it does. |
AppGantry deliberately returns 404 rather than 403 for resources outside your tenancy, so a 404 on something you believe exists usually means you are addressing it from the wrong organization or with the wrong token.
Two reads answer 404 for a second reason as well, and with a different body: reading an organization's SSO connection, and reading its storage-account onboarding request. See The two exceptions.
402 Payment Required¶
Payment-required responses are the billing gate. Five codes, in two different categories — the distinction matters because three of them lock the organization and two don't:
Organization write locks¶
The organization is in a billing state that blocks all writes until a human fixes it. Nothing you change about the request will help.
Each of these codes maps to exactly one details.lock_reason:
error |
details.lock_reason |
Meaning | How to clear it |
|---|---|---|---|
billing_suspended |
suspended |
Payment problems went unresolved | Settle the outstanding balance |
payment_required |
awaiting_payment |
The organization committed to a paid plan but no payment method has been captured yet | Add a payment method |
trial_ended |
grace |
A no-card trial ran out of time and no card has been added since | Add a payment method |
So the response carries the state twice — once as the code you match
on, once in details:
{
"error": "billing_suspended",
"message": "This organization is suspended.",
"details": { "lock_reason": "suspended" }
}
{
"error": "trial_ended",
"message": "Your trial has ended. Add a payment method to continue.",
"details": { "lock_reason": "grace" }
}
suspended, awaiting_payment, and grace are the only values to
expect in lock_reason.
grace means an expired trial, not a failing card. The trial timer
ran out on an organization that never had a payment method; adding one
clears it.
Under trial_ended, uploads, downloads, and organization-scoped writes
are all refused until a payment method is added.
Under suspended, new builds are refused while downloads of existing
builds keep working for a configured period, so testers aren't cut off
the moment a card expires.
A payment that is still being retried is not a write lock
While the payment provider is retrying a charge — the past-due,
dunning-in-progress state — the organization is not locked and
keeps writing normally. A 402 arrives only once the retries are
exhausted and the organization becomes suspended.
Per-request billing gates¶
The organization is not locked. This particular request was refused
because of the balance or cap it would consume. These responses do
not carry details.lock_reason.
error |
Meaning | How to clear it |
|---|---|---|
prepaid_credit_exhausted |
The organization's pre-paid balance reached zero. A trial that burns through its starter credit reports this too — not trial_ended |
Top up, or add a payment method if you're on a trial |
spend_cap_exceeded |
The request would exceed the organization's monthly spend cap | Raise or clear the cap, or wait for the next period |
What is gated, and what isn't¶
Gated: minting an upload URL, completing an upload, and minting a download URL. Never gated: reading metadata — build lists, tester lists, settings, the audit feed — so a locked organization is never a locked-out organization.
Reservations. Each admitted upload or download briefly reserves its projected cost while in flight. Two consequences:
- A 402 can appear slightly before your displayed balance reaches zero, because concurrent requests count against it.
- A download URL that is minted but never used holds a small reservation that releases itself shortly afterwards.
A 402 carries no side effect and is safe to retry once the underlying billing state is fixed.
409 Conflict¶
error |
Meaning |
|---|---|
already_exists |
A uniqueness constraint would be violated — a duplicate name, email, or identifier. |
dependency_exists |
Something still refers to the resource you're deleting. Remove the dependants first. |
If you're retrying a create that may have partially succeeded, treating
already_exists as success is usually the right call. POST
/api/v1/builds/initiate is the exception — see below.
409 when initiating a build upload¶
Initiating an upload claims one identity: the project, its platform, the
version name, and the build number. Two different situations answer 409
already_exists on that claim:
- a completed build already holds that identity, or
- an earlier
initiatefor that identity is still pending — the upload it minted was never completed.
The second one is the one that catches a pipeline out. A pending row holds its claim until it is swept, and it is swept only after it expires, so a re-initiate that follows a failed upload can be refused for a while even though no build was ever created.
So a 409 here does not tell you a build exists, and treating it as success reports a green pipeline for an artifact nobody can install. Recover by giving the upload a fresh identity (a new build number, or a new version name) or by waiting for the pending row to be swept and initiating again. See CI → Failure modes worth handling.
No operation declares a 409 in the published schema. It is raised
while the request is being handled rather than declared per operation,
so nothing derives it into this reference — the same gap as
429. Handle it by status.
413 Content Too Large¶
error |
Meaning |
|---|---|
payload_too_large |
The request or the stored artifact exceeded its size limit. |
Build artifacts are capped at 500 MiB, checked against the stored
object at upload-completion time. Icons are capped at 1 MiB.
Exceeding either is a 413 payload_too_large, not a 400
quota_exceeded — size limits and count limits are different things.
429 Too Many Requests¶
error |
Meaning |
|---|---|
rate_limit_exceeded |
You're sending requests faster than allowed, or you hit one of the per-resource application limits below. |
A 429 comes from one of two places, and they do not look the same:
- The edge, in front of the API. The body may not be the AppGantry envelope at all, because the request never reached application code.
- The application, which uses the flat envelope with
error: "rate_limit_exceeded"and, for some limits, a populateddetails.
No operation declares a 429 in the published schema. That is a gap
in the schema rather than a guarantee: handle 429 by status class on
every call.
Application-level 429s¶
These are deliberate product limits rather than traffic shaping, so they are worth handling specifically rather than as generic throttling:
| Refused request | Limit | Notes |
|---|---|---|
| Re-sending a pending organization or project member invitation | One send per invitation per ~5 minutes, counting the original | A missing invitation — one that never existed, or that expired past its 14 days and was swept — an already-accepted one, and an already-revoked one all answer 404 rather than 429, and those three are deliberately indistinguishable from one another so the endpoint can't be used to probe. The 404 and the 429 are not interchangeable: a 429 tells you the invitation is still pending and was sent too recently, whether that send was the original invitation or a later resend |
| Re-sending a pending organization or project tester notification | One send per pending grant per ~5 minutes, counting the one sent on add | The same shape. A grant that never existed, one that has already attached — a tester grant is not accepted, it attaches when the invited address is verified — and one that has been cancelled all answer 404, and those three are indistinguishable from one another; the throttle is the separate 429 |
| Requesting a personal data export | One new request per 24 hours, and at most 5 in a rolling 30 days | Carries a structured details — see below |
| Downloading a ready export archive | 20 downloads per archive | A plain message; request a fresh export to download again |
| Initiating a build upload | 25 uploads in flight per project | Refused with 429 once the project holds 25 uploads that have been started and not completed. A ceiling on work in flight rather than a plan quota, so it clears as they complete or expire — it is not a 400 quota_exceeded |
A repeat export request that lands while one is still being prepared is not refused: it collapses onto the in-flight job and consumes no quota.
The export-generation refusal carries an explanation in details
because refusing a data-subject access request has to say why:
{
"error": "rate_limit_exceeded",
"message": "You've reached the limit for how often a fresh copy of your personal data can be generated. Your existing export is still available to download, and you can request a new one after the current period.",
"details": {
"reason": "repeat_export_rate_limited",
"explanation": "…why the refusal is permitted, and that it never blocks a first request in a period…",
"right_to_complain": "…your right to complain to a supervisory authority and to a judicial remedy…"
}
}
Match on details.reason if you want to tell that refusal apart from
ordinary throttling; treat the two prose fields as human-readable text
that may be reworded.
One family of throttles deliberately does not produce a 429. Signup verification, password reset and the verification for an email change each have a ~5-minute per-recipient cooldown of their own, and a second email of that kind inside its own window is silently absorbed: the response looks like a success, because answering differently would confirm whether an address is on file. A send of one kind never absorbs another. See Authentication → Email verification.
See Rate limits for handling.
503 Service Unavailable¶
error |
Meaning |
|---|---|
storage_backend_unavailable |
The storage backend couldn't be resolved or reached. Transient. |
captcha_unavailable |
The captcha verification service couldn't be reached, so the request was refused rather than admitted unverified. |
store_connection_unavailable |
An app store provider couldn't be reached to validate credentials. |
These are retryable and the request had no side effect. Four
operations advertise a Retry-After on their 503 in the schema:
POST /api/v1/builds/initiate and
GET /api/v1/builds/{build_id}/download (a transient storage-backend
lookup failure), and the two audit reads (the audit store didn't answer
within the read timeout). Honour the header when it is present and fall
back to exponential backoff when it isn't. Other 503s — a captcha or
store-provider outage, for example — carry no Retry-After, so don't
assume every 5xx does.
500 Internal Server Error¶
error |
Meaning |
|---|---|
internal_error |
Something went wrong on our side. |
A 500 on a read can be retried with exponential backoff.
A 500 on a write is different. The failure may have happened after
the write committed, and there is no Idempotency-Key for the server to
collapse your retry onto the original attempt — so a blind retry can
duplicate the work. Read back the resource or collection first to see
whether the operation landed, then retry only if it didn't. See
Idempotency and retries.
The audit reads are the exception to "500 means we broke": they return 500 specifically when the audit store rejected the query, so a 500 there is about the query, not about the service being down.
If it persists, tell us with the time,
the operation, and the message.
Retry-After and other error headers¶
Headers carry information no status code can:
| Header | On | Meaning |
|---|---|---|
Retry-After |
503 on upload initiate, build download, and the two audit reads; may appear on 429 | Seconds to wait before retrying. Honour it in preference to your own backoff. |
X-Audit-Truncated |
200 on the two audit reads | Present and true when the query hit its result cap, so the trail you got back is incomplete. Absent when the result is complete. |
X-Audit-Truncated is not an error, but it is the most easily missed
correctness signal in the API: a truncated audit read looks exactly like
a complete one in the body. The caps are 1000 events for a single-day
query and 5000 for a date-range export. Narrow the range, or walk it day
by day, when you see it. See Audit events.
Feature limits¶
Some limits are counts and produce quota_exceeded (400):
| Count limit | Value |
|---|---|
| Organizations per developer | 5 |
| Projects per organization | 50 |
| Passkeys per developer | 20 |
Others are sizes and produce payload_too_large (413):
| Size limit | Value |
|---|---|
| Build artifact size | 500 MiB |
| Icon size | 1 MiB (PNG, JPEG, or WebP) |
Neither kind is a billing error. Clearing a count limit means deleting something; clearing a size limit means sending something smaller.
A third kind is refused with rate_limit_exceeded (429): a rate over
time, or a ceiling on how many of something may be in flight at once.
Clearing one means waiting:
| Rate limit | Value |
|---|---|
| Sends of one pending member invitation, original or resend | 1 per ~5 minutes |
| Sends of one pending tester-grant notification, on add or resend | 1 per ~5 minutes |
| New personal data exports | 1 per 24 hours, and 5 per rolling 30 days |
| Downloads of one ready export archive | 20 |
| Uploads in flight per project | 25 |
Handling errors well¶
- Handle the bare 404s separately. They are the only responses
that are not the full envelope — see
The two exceptions. Indexing
detailson a bare 404 raises. - Switch on
error, not onmessage. Messages get reworded. - Treat unknown codes by status class. New codes get added.
- Read
detailsfor the codes that use it —lock_reasonon organization write locks,errorson validation failures. - Distinguish retryable from terminal. 429 and 503 are retryable
with backoff, and so is a 500 on a read. A 500 on a
non-idempotent write needs a check first — see
Idempotency and retries.
400 (including
validation_error), 401 (after one refresh), 403, 404, and 409 are not retryable. 402 is retryable only after a human fixes billing. - Never log tokens when logging failed requests.
See also¶
- API conventions: the envelope in context.
- Rate limits: 429 in detail.
- Authorization: why you got a 403.
- Billing, usage & caps: clearing a 402.