{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid",
"requestId": "req_8f2a",
"fields": [{ "path": "email", "code": "EMAIL_TAKEN" }],
"retryable": false
}
}
Every error should have the same shape, a correct HTTP status, and a stable machine-readable code. The client checks the code, never the human message, because messages change and get translated.
A good error response contains:
- A stable
code, likeVALIDATION_FAILED. The client logic switches on it. - Field errors with a path, so the form can show each error next to the right input.
- A
requestId. It lets support staff and logs match up the failure. - A retry hint, like a
retryableflag, or aRetry-Afterheader on 429 and 503 responses. It tells the client whether an automatic retry makes sense.
On the client, I wrap fetch in one API layer. It turns responses into typed errors:
- network errors
- 401 (refresh the token or redirect to login)
- 403
- 404
- validation errors
- server errors
Components then decide how to show the error:
- inline messages next to fields
- a toast for short-lived failures
- an error boundary for a whole page that failed
The text the user reads comes from the client's i18n catalogue (the translated messages), keyed by the error code.
- Every error has the same shape and a correct status code.
- Stable error codes, not messages, drive the client logic.
- Include field-level errors and a request id.
- Include retry hints such as
retryableorRetry-After. - A single API layer maps responses to typed errors.
Read more