When things go wrong
Which failures you should retry, which mean fix the request, and how to show a useful message to whoever is waiting.
Sarah's booking page needs to tell a customer "that email's already registered" rather than "Error 422". Her nightly job needs to know the difference between "try again in a minute" and "this will never work".
Everything comes back as JSON with a message. Validation problems also come back
with a per-field breakdown.
The short version
Retry 429, 503 and 5xx, backing off each time. Everything else needs the
request changed.
The full list
| You get | What happened | Retry? |
|---|---|---|
400 | No X-Location-Id on a request that needs one | No — add it |
401 | Missing, expired or revoked credential | No — sign in again |
403 | Allowed in, not allowed to do this — or the key can't reach this site | No — fix scopes or grants |
404 | No such thing, or it's switched off | No |
409 | Two retries of the same operation raced | Sometimes — see Safe retries |
419 | The browser's security token went stale | Yes, once, after refreshing it |
422 | The data was wrong | No — fix it and resubmit |
429 | Too many requests | Yes, after waiting |
503 | The site is still being created | Yes, with backoff |
5xx | Our fault | Yes, with backoff |
Showing a validation error to a person
A 422 names every field that failed:
{
"message": "The email has already been taken.",
"errors": {
"email": ["The email has already been taken."],
"phone": ["The phone field is required."]
}
}message is the first problem, ready to put in a toast. errors is what you attach
to form fields — the keys match the field names you submitted, so binding is direct.
Nested fields arrive dotted, like addresses.0.postal_code.
const response = await createCustomer(form);
if (response.status === 422) {
const { errors } = await response.json();
for (const [field, messages] of Object.entries(errors)) {
showFieldError(field, messages[0]);
}
return;
}Telling the two kinds of 403 apart
Both mean "not allowed", and the fixes live in different places:
- "…does not have access to the requested location." The credential is fine; it hasn't been given this site. Add a site or group grant.
- "You do not have permission to perform this action." It can reach the site but
not do this. The response includes
required_permission, naming exactly what to add.
Read the message. It always says which.
The 403 that means "verify your email"
If your application requires verified email addresses (a switch on the application's page in the console), a signed-in person who hasn't clicked the link in their verification email is refused on every business request:
{
"message": "Verify your email address to continue.",
"error": { "type": "email_unverified" }
}It's not a permissions problem, so don't send them to an admin. Check error.type (the SDK
exposes it as isEmailUnverified), and show a "check your inbox" screen with a resend
button. Sign-in, sign-up, and the verify and resend
routes still answer, so they can finish:
try {
await hyphabase.clients.list();
} catch (error) {
if (error instanceof HyphaBaseError && error.isEmailUnverified) {
await hyphabase.auth.resendVerification(); // emails a fresh link
showCheckYourInbox();
}
}When they click the link, your app's /verify-email page redeems the token with
hyphabase.auth.verifyEmail({ email, token }). The next request works; they don't need
to sign in again.
Rate limits
Sign-in endpoints are throttled harder than the rest, because they're what gets attacked:
| Endpoint | Limit |
|---|---|
| Signing in | 5/min per email and IP, 20/min per IP |
| Registering | 3/min per IP |
A 429 carries Retry-After. Use it rather than guessing — and never retry a failed
sign-in in a loop, because you'll lock out the real person.
A retry loop worth copying
Retry only what can succeed unchanged, cap the attempts, and honour Retry-After.
For writes, send an Idempotency-Key so a retry can't double-book —
Safe retries covers that.
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
async function request(url, options = {}, attempt = 1) {
const response = await fetch(url, { ...options, credentials: 'include' });
// The security token went stale. Refresh it and retry exactly once.
if (response.status === 419 && attempt === 1) {
await fetch('https://api.hyphabase.com/sanctum/csrf-cookie', {
credentials: 'include',
});
return request(url, options, attempt + 1);
}
if (RETRYABLE.has(response.status) && attempt < 5) {
await new Promise((resolve) => setTimeout(resolve, waitFor(response, attempt)));
return request(url, options, attempt + 1);
}
return response;
}
function waitFor(response, attempt) {
const retryAfter = Number(response.headers.get('Retry-After'));
return Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 2 ** attempt * 250;
}Don't retry a 503 forever
A 503 while a new site is being created clears in seconds. One that persists for
minutes means the site failed to build — check its provisioning_status and
provisioning_error instead of looping.