HyphaBase

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 getWhat happenedRetry?
400No X-Location-Id on a request that needs oneNo — add it
401Missing, expired or revoked credentialNo — sign in again
403Allowed in, not allowed to do this — or the key can't reach this siteNo — fix scopes or grants
404No such thing, or it's switched offNo
409Two retries of the same operation racedSometimes — see Safe retries
419The browser's security token went staleYes, once, after refreshing it
422The data was wrongNo — fix it and resubmit
429Too many requestsYes, after waiting
503The site is still being createdYes, with backoff
5xxOur faultYes, 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:

EndpointLimit
Signing in5/min per email and IP, 20/min per IP
Registering3/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.

Next

On this page