Choose HTTP Error Statuses Without Hiding the Real Failure
APIs & the webA guide to distinguishing between 4xx and 5xx errors using a private photo album scenario to drive client-side recovery.
On this page
The Philosophy of Error Signaling
HTTP status codes are not just labels; they are instructions for the client. A 500 Internal Server Error is a failure of the server to handle a situation, whereas a 4xx code is a signal that the client must change its request to succeed. When an API collapses every domain error into a 500 or a generic 400, it strips the client of the ability to recover automatically.
Mapping Failures to Status Codes
400 covers malformed requests and can also cover input validation under a documented contract; 422 is a useful choice when well-formed content cannot be processed. 401 means valid authentication credentials are absent and requires WWW-Authenticate. 403 means the server refuses the request; it does not universally imply the caller authenticated successfully. A privacy policy may return 404 for inaccessible resources.
409 describes a conflict with current resource state. If a supplied If-Match precondition fails, use 412 Precondition Failed instead of calling every ETag mismatch 409. A version field checked by application logic may produce 409 under that API contract.
Worked Example: The Photo Album Trace
Scenario: A client attempts to update a photo in a private album. Below is the trace of different failure modes and the resulting client action.
| Request | Server State/Condition | Status Code | Header/Body Detail | Client Action |
|---|---|---|---|---|
PATCH /photos/abc-123 | ID abc-123 is not a valid UUID | 400 | {"error": "Invalid ID format"} | Fix request syntax |
PATCH /photos/uuid-1 | Token expired | 401 | WWW-Authenticate: Bearer | Refresh token/Re-login |
PATCH /photos/uuid-2 | User A tries to edit User B’s photo | 403 (or 404) | {"error": "Access denied"} | Stop attempting access |
PATCH /photos/uuid-3 | Resource does not exist | 404 | {"error": "Photo not found"} | Remove from local cache |
PATCH /photos/uuid-4 + If-Match | ETag mismatch | 412 | {"error":"Precondition failed"} | GET → reconcile → retry |
PATCH /photos/uuid-5 | Caption is 10,000 chars | 422 | {"field": "caption", "msg": "Too long"} | Prompt user to shorten |
PATCH /photos/uuid-6 | Database connection timed out | 500 | {"error": "Internal error"} | Retry with exponential backoff |
Implementation Nuances
When implementing these, avoid the temptation to use a single catch (Exception e) block that returns 500. Instead, map specific domain exceptions to these codes.
Failure Case: The 401/403 Loop
If a client receives a 401, it should attempt to refresh the token. If it receives a 403, it must not refresh the token, as the identity is already verified; the permission is simply missing. Refreshing on a 403 creates an infinite loop of useless requests.
Exercise
Scenario: You are building a file-sharing API. A user tries to upload a file to a folder. The following happens:
- The folder ID is provided as a string, but the system expects an integer.
- The user is logged in, but the folder is marked as “Read Only” for their group.
- The user tries to upload a file with the exact same name as one that already exists in that folder, and the business rule forbids duplicates.
Question: Which HTTP status codes should be returned for these three cases, and why?
Answer:
400 Bad Request: The input type is syntactically incorrect for the expected identifier.403 Forbidden: The user is authenticated, but the specific action (write) is forbidden by the resource’s permissions.409 Conflict: The request cannot be completed because of the current state of the resource (the existing filename conflict).