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

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

  1. 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
  2. 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.txt
    
    HTTP/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:6AA45105

    The request id is the field to quote in a bug report. An error without one leaves support with nothing to search.

  3. 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"
    }
  4. 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-repository

    Note that status came back as the string "404", not the number 404. A client that compares it with === against a number never matches.

  5. 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
  6. 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
  7. Step 7.

    Request the route that fails inside the handler and read what the body admits.

    curl -s -D - http://localhost:8787/v1/orders/crash
    
    HTTP/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

Sign: The suite passes, yet the client crashes on every failed call.Cause: The tests assert on the status only. The body shape of an error is never asserted, so a framework upgrade that changes error from a string to an object breaks callers without turning any test red.
Sign: A numeric comparison against the status field in the body never matches.Cause: The field is serialized as a string. The response above returns the status inside quotes while the HTTP status line carries the number 404, so a strict equality check fails on a body that looks correct.
Sign: Errors are clean on staging and leak stack traces in one environment.Cause: The framework prints the stack when it does not run in production mode. The environment variable is set per deployment, so the leak follows the deployment, not the code, and a staging-only test never sees it.

What to check next

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.

intermediate8 minpublished updated Maks Verny