Typed API errors: handling Norbelys errors without guessing from status codes
A 400 alone tells you nothing useful. How to build error handling around the Norbelys API's structured error shape instead of parsing status codes and hoping.
By Gabriel Lara, Developer Relations, Norbelys
Founder-reviewed ·How we research and correct articles
A lot of API client code looks like this:
if (res.status === 400) {
// ...something is wrong with the request, probably?
} else if (res.status >= 500) {
// ...retry, maybe?
}
That’s status-code guessing, not error handling. A 400 on a POST /v1/people could mean a malformed email, a missing required field, a
duplicate that violates a uniqueness constraint, or half a dozen other
things — and the status code alone can’t tell those apart. If your code
branches only on res.status, every one of those distinct failures gets
the same generic “something went wrong” treatment, which means you can
never show a user which field to fix, or decide programmatically whether
a retry makes sense.
The Norbelys API returns one consistent, structured error body on every failure, specifically so client code can branch on the reason, not just the status.
The error shape
Every non-2xx response from the API returns the same envelope, regardless of which endpoint or which resource:
{
"error": {
"code": "validation_error",
"message": "email must be a valid email address",
"field": "email"
}
}
code— a stable, machine-readable string. This is what your code should branch on — never the human-readablemessage, which can change wording without notice.message— a human-readable explanation, safe to log or surface in a UI, not meant to be parsed.field— present on validation errors to point at exactly which input field failed, absent otherwise.
One shape, every endpoint, alongside the standard HTTP status it’s returned with. No bespoke per-resource error format to learn.
Handling the three failure classes distinctly
Most integration bugs come from treating validation errors, rate limits and auth failures as the same kind of problem. They aren’t — each has a different correct response.
Validation errors (4xx, retrying won’t help)
A validation error means the request itself is malformed. Retrying the exact same payload will fail the exact same way every time — the fix has to change the request, not the timing.
async function createPerson(payload, apiKey) {
const res = await fetch("https://api.norbelys.com/v1/people", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (res.ok) return res.json();
const { error } = await res.json();
if (error.code === "validation_error") {
// Surface error.field directly next to the offending form input.
throw new FieldError(error.field, error.message);
}
throw error;
}
Rate limits (429, retry — but not immediately)
A 429 is not a bug in your request; it’s a signal you’re sending faster
than your current limit allows. The correct response is to slow down and
retry later, never to hammer the endpoint again immediately — see
rate limits and backoff for
the full retry strategy, including how to respect the response’s retry
signal rather than guessing an interval. Any retried POST should also
carry an Idempotency-Key
so a rate-limited retry can never create the same record twice.
if (res.status === 429) {
const { error } = await res.json();
// error.code === "rate_limited" — schedule a retry with backoff,
// don't loop immediately.
}
Auth failures (401 vs. 403 — genuinely different problems)
These look similar from a status code alone but mean opposite things operationally:
- 401 — the credential itself is invalid, missing, or expired. No amount of retrying fixes this; the caller needs a new API key or token.
- 403 — the credential is valid but doesn’t have permission for this specific action (an org-scoped key trying to reach a resource outside its organization, for example). Retrying with the same key never helps either, but the fix is different: the credential needs different permissions, not replacement.
if (res.status === 401) {
// Credential invalid or expired — prompt for re-authentication.
} else if (res.status === 403) {
// Credential valid, action not permitted — this is a permissions
// problem, not a login problem. Don't prompt for the same re-auth flow.
}
Conflating the two usually shows up as a broken “please log in again” prompt firing on a permissions issue that a fresh login can’t fix.
Trying it against a real endpoint
Building one central handler instead of scattering if statements
The pattern that scales: parse the error envelope once, in one place, and
dispatch on code. Every call site then gets consistent behavior for free
instead of re-implementing status-code guesswork per endpoint.
class NorbelysApiError extends Error {
constructor({ code, message, field, status }) {
super(message);
this.code = code;
this.field = field;
this.status = status;
}
}
async function norbelysRequest(path, init, apiKey) {
const res = await fetch(`https://api.norbelys.com/v1${path}`, {
...init,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...init?.headers,
},
});
if (res.ok) return res.json();
const body = await res.json();
throw new NorbelysApiError({ ...body.error, status: res.status });
}
Every caller now handles one exception type and reads .code — no
duplicated status-code branching spread across the codebase, and no place
where a validation error and a rate limit accidentally get treated the
same way.
See the developer portal for the complete OpenAPI spec,
including every error.code value per endpoint, and
cursor pagination for the other
half of building a reliable list-reading client against
people or any other resource.
Typed error handling FAQ
Should I retry every non-2xx response?
No. Retry rate limits and transient server errors with backoff; never retry a validation error or a 401/403 without first changing the request or credential, since the same payload will fail identically every time.
Is error.message safe to show directly to end users?
It's written to be human-readable and safe to display or log, but it isn't a stable API contract the way error.code is. Build user-facing copy around the code, and treat message as a reasonable default or debug string, not a translation key.
Does every endpoint use the same error envelope?
Yes — { error: { code, message, field? } } is uniform across the whole API, not a per-resource format. Learning it once on one endpoint means you never have to relearn it for another.
What's the difference between a 401 and a 403 in practice?
A 401 means the credential itself is invalid or missing — fix it by authenticating again. A 403 means the credential is valid but lacks permission for that specific action — fix it by granting permission, not by logging in again.