A good API error-handling design separates what the client needs to know from what developers need for debugging.
1. Use a consistent error envelope
For example:
{
"error": {
"code": "INVALID_ARGUMENT",
"message": "The email address is not valid.",
"details": {
"field": "email"
},
"request_id": "req_7f31a"
}
}
Clients can reliably branch on code, while message is safe to display.
2. Map errors to appropriate HTTP status codes
A typical scheme:
| Status | Use for | Example code |
|---|
400 | Malformed/invalid request | INVALID_ARGUMENT |
401 | Missing/invalid authentication | UNAUTHENTICATED |
403 | Authenticated but not allowed | FORBIDDEN |
404 | Resource doesn't exist | NOT_FOUND |
409 | State/conflict problem | ALREADY_EXISTS |
422 | Valid JSON but failed validation | VALIDATION_FAILED |
429 | Rate limiting | RATE_LIMITED |
500 | Unexpected server failure | INTERNAL_ERROR |
503 | Temporary dependency/service failure | SERVICE_UNAVAILABLE |
Don't expose implementation-specific distinctions that clients shouldn't depend on.
3. Never serialize raw exceptions
Bad:
{
"error": "NullReferenceException at UserService.cs:184..."
}
Instead, have a centralized exception handler:
request
↓
route/controller
↓
business logic
↓
exception
↓
global error middleware
├── log full exception + stack trace
└── return sanitized error response
For an unexpected exception, the client might receive only:
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Something went wrong while processing your request.",
"request_id": "req_7f31a"
}
}
Meanwhile, your logs contain the stack trace, exception type, request ID, and relevant diagnostic context.
4. Give clients stable error codes
Don't make clients parse human-readable messages:
if (response.message.includes("already exists")) ...
Prefer:
if (response.error.code === "EMAIL_ALREADY_REGISTERED") ...
Messages can change, be localized, or become more descriptive without breaking clients.
5. Validate at the boundary
Return actionable validation errors:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "One or more fields are invalid.",
"details": {
"fields": {
"email": "Must be a valid email address.",
"age": "Must be at least 18."
}
}
}
}
Avoid returning sensitive information—for example, don't reveal whether a particular account exists if that would enable account enumeration.
6. Correlate responses with logs
A request_id/correlation ID is extremely useful:
HTTP/1.1 500 Internal Server Error
X-Request-ID: req_7f31a
The client can tell support:
"Request req_7f31a failed."
Your logs can then locate the exact stack trace without exposing it to the client.
7. Be careful with errors from dependencies
Don't blindly forward database, payment-provider, or internal-service errors:
Database error: duplicate key constraint users_email_key
Translate them into an API-level error:
{
"error": {
"code": "EMAIL_ALREADY_REGISTERED",
"message": "That email address is already registered."
}
}
8. Have an explicit "unknown error" fallback
Your final exception boundary should guarantee that every unexpected failure produces a safe response. It should also log the original exception.
Conceptually:
try:
handle_request()
catch KnownApiError e:
log_if_needed(e)
return e.status, e.public_response
catch Exception e:
request_id = get_request_id()
logger.error(e, request_id=request_id)
return 500, {
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred.",
request_id: request_id
}
}
The key principle is:
Detailed internally, predictable externally.
Clients get a stable status/code/message/details contract; developers get the full exception, stack trace, and context through secure logging/observability.