How to test API error responses
Ask the API for a resource that cannot exist and keep the status, the content type and the body together: curl -s -D headers.txt -o body.json -w 'http_code=%{http_code}' https://api.example.com/v1/orders/9999. A usable error carries a 4xx or 5xx status, a typed body with a field the client can branch on, and no stack trace.
Why check this
Error paths reach production untested because the happy path passes and nobody asks what the client does with the failure. Run this before a client integration sign-off and again after any change to the error handler or the framework version. It catches the shape that breaks callers: an error announced with status 200, so every client that branches on the status treats the failure as data and renders an empty screen.
Prerequisites
- curl 7.0 or later. See the curl manual.
- Node 22 for reading fields out of the body.
- A local target for the two failure shapes you cannot ask a public API to produce. Save this as
error-api.jsand start it withnode error-api.js:
const http = require('node:http');
http.createServer((req, res) => {
if (req.url === '/v1/orders/soft') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ error: 'order not found' }));
}
if (req.url === '/v1/orders/crash') {
const err = new Error('connect ECONNREFUSED 10.0.0.7:5432');
res.writeHead(500, { 'content-type': 'text/html' });
return res.end('<pre>' + err.stack + '</pre>');
}
res.writeHead(404, { 'content-type': 'application/problem+json' });
res.end(JSON.stringify({ title: 'Not found', status: 404 }));
}).listen(8787, () => console.log('error-api on 8787'));
Stop it with Ctrl+C when the run is over.
Steps
- Step 1.
Ask a real API for a resource that does not exist, and save the headers and the body side by side.
curl -s -D headers.txt -o body.json -w 'http_code=%{http_code} content_type=%{content_type} size=%{size_download}\n' 'https://api.github.com/repos/curl/curl-h2check-does-not-exist'http_code=404 content_type=application/json; charset=utf-8 size=132 - Step 2.
Read the status line and the content type from the saved headers.
grep -i -E '^HTTP|^content-type|^x-github-api-version-selected|^x-github-request-id' headers.txtHTTP/2 404 content-type: application/json; charset=utf-8 x-github-api-version-selected: 2022-11-28 x-github-request-id: C296:198AB4:11C90BB:1118999:6AA45105The request id is the field to quote in a bug report. An error without one leaves support with nothing to search.
- Step 3.
Print the body.
cat body.json{ "message": "Not Found", "documentation_url": "https://docs.github.com/rest/repos/repos#get-a-repository", "status": "404" } - Step 4.
Pull the fields out with a parser, not with your eyes. This is the test of machine readability.
node -e "const e=JSON.parse(require('fs').readFileSync('body.json','utf8'));console.log(e.status, '|', e.message, '|', e.documentation_url)"404 | Not Found | https://docs.github.com/rest/repos/repos#get-a-repositoryNote that
statuscame back as the string"404", not the number404. A client that compares it with===against a number never matches. - Step 5.
Measure an error that carries nothing at all, for contrast.
curl -s -o /dev/null -w 'status=%{http_code} type=%{content_type} bytes=%{size_download}\n' 'https://httpbin.org/status/500'status=500 type=text/html; charset=utf-8 bytes=0 - Step 6.
Against the local target, request the route that reports a failure with status 200.
curl -s -w '\nHTTP %{http_code} %{content_type}\n' http://localhost:8787/v1/orders/soft{"error":"order not found"} HTTP 200 application/json - Step 7.
Request the route that fails inside the handler and read what the body admits.
curl -s -D - http://localhost:8787/v1/orders/crashHTTP/1.1 500 Internal Server Error content-type: text/html <pre>Error: connect ECONNREFUSED 10.0.0.7:5432 at Server.<anonymous> (…/error-api.js:9:17) at Server.emit (node:events:519:28) …The body names an internal host and a port. That is a finding, not a formatting problem.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 4xx with a JSON body and a stable field | The client can branch on the error | Nothing. Record the field names as part of the contract. |
| Status 200 with an error field | The failure is invisible to status checks | Raise it. Every caller that tests the status alone treats it as success. |
| 5xx with bytes=0 | The client learns nothing but the class | Ask for a body with an identifier. Support cannot trace a blank 500. |
| content-type: text/html on an error | The error path skipped the API serializer | Usually a framework default page. The JSON client fails to parse it. |
| A stack trace, a file path or a host in the body | Internal detail is leaking | File as security. Set the framework to production mode and retest. |
| The same message for 401 and 404 | Deliberate, or a bug | Confirm with the team. Hiding existence is a choice, not an accident. |
Common mistakes
What to check next
- How to check HTTP status code: the status class is half of the error contract and the fastest part to assert.
- How to test API with invalid input: the requests that should produce these errors in the first place.
- How to test API with curl: the flags used above, explained one at a time.
- How to check API versioning: a version miss often returns an HTML error page instead of a JSON one.
- JSON schema validator: pin the error body to a schema so a changed shape fails a test.
FAQ
What is a good API error response format?
One status that matches the class of the failure, one machine-readable code that never changes wording, a human message, and a request id. RFC 9457 problem details gives you that with application/problem+json. Any stable shape works if it is documented and asserted.
How do I test API error handling without breaking production data?
Errors that need no state come from bad identifiers, bad input and missing authentication, which are all read-only. Failures that need a broken dependency run against a local target like the one above.
Should an error body be JSON when the request asked for JSON?
Yes. A client that sent Accept: application/json and receives text/html on a 500 fails at the parser, before any error handling runs. Check the content type on error responses, not only on successful ones.
Does a 200 with an error field ever make sense?
In batch endpoints, where part of the payload succeeds and part fails, the envelope is 200 and each item carries its own status. For a single-resource call it is a defect.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny