Skip to content
Control Plane Labs

500 Internal Server Error

Server error response defined by RFC 9110 §15.6.1.

Written and maintained by Ben Ennis

Last reviewed July 27, 2026 · How we verify this

Common causes at a glance

  • An unhandled exception escaping a request handler
  • Database connection pool exhausted or the database refusing connections
  • A missing or renamed environment variable after a deploy
  • A worker killed by the OOM killer mid-request

Reproduce it

curl -sS -o /dev/null -w 'code=%{http_code}\n' https://api.example.com/v1/orders

What 500 Internal Server Error means

500 is what a server says when it has nothing useful to say. Every other 5xx code names a specific failure mode; 500 is the residue. A handler threw, a driver raised on a closed connection, a template referenced a field that was not there. The framework catches whatever escaped and emits 500 because the alternative is dropping the connection.

The operationally important property is that 500 tells the client nothing on purpose. The body must never contain a stack trace, a SQL statement, a filesystem path, or a framework debug page. Those leak directory layout, dependency versions, and sometimes credentials to anyone who can trigger the error. From the outside a leaky 500 and a clean one look identical to monitoring, which is how a staging debug renderer survives in production for months.

What the spec says

RFC 9110 §15.6.1 defines 500 in two lines: the server encountered an unexpected condition that prevented it from fulfilling the request. The surrounding §15.6 sets the class-wide rule that a 5xx server is declaring itself aware that it erred. The spec says nothing about the body, so the shape of your error payload is yours; RFC 9457 problem details is the usual answer for APIs. Unlike 501, a 500 is not heuristically cacheable.

What actually causes it

Most real 500s are application bugs reached by a request shape nobody tested: a query parameter that is absent rather than empty, a user record with a null field, a payload far larger than the fixture. The second cluster is environmental — a database that hit max_connections, a secret that failed to mount, a migration applied to one replica but not the others. If the rate steps up at a release boundary and stays there, the deploy is the cause; if it tracks traffic, look at pool sizes and memory limits before reading any code.

How to debug it

First establish who generated it. An application 500 carries your framework’s headers and error format; a 500 emitted by nginx after the upstream died looks generic and will not appear in your application logs at all:

curl -sS -D - -o /dev/null https://api.example.com/v1/orders | head -20

Then pull the failing request out of your logs by request ID and read the trace. Before anything else, confirm the response is not leaking internals: if the body contains a file path or a stack frame, turn the debug renderer off now. The header inspector shows whether a proxy rewrote the status on the way out.

Whether it is safe to retry depends on the method, not the 500

A 500 tells you the server gave up, not whether your write happened before it did. A handler can throw after committing a database transaction, and the client sees the same 500 as one that threw before touching the database. RFC 9110 §9.2.2 defines PUT, DELETE, and the safe methods as idempotent because “the request can be repeated automatically if a communication failure occurs before the client is able to read the server’s response” — repeating them has the same intended effect as sending them once, so an automatic retry is sound. POST is not on that list, and the spec is explicit: a client SHOULD NOT automatically retry a non-idempotent request unless it has some separate way to know the semantics are safe, or some way to detect the original request never landed.

This is exactly the failure mode idempotency keys exist to close. Stripe’s API saves the status code and body of the first request under a given key — including a 500 — and returns that same saved result for any retry with the same key, so a client can retry blindly after a timeout or a 500 without risking a duplicate charge. Without a key, retrying a POST after a 500 is a guess: if the handler failed before its side effect, the retry is free; if it failed after, the retry duplicates it. RFC 9110 also puts a tighter rule on intermediaries than on clients: a proxy MUST NOT automatically retry non-idempotent requests on your behalf, precisely because it has no way to know which case it is looking at.

Design for this before the 500 happens: make writes idempotent by construction, or accept an idempotency key, rather than deciding retry policy after the fact.

Working examples

curl

# -D - prints response headers; the Server header tells you which hop answered.
curl -sS -D - -o /dev/null https://api.example.com/v1/orders

# Loop a route to see whether the failure is deterministic or intermittent.
for i in $(seq 1 20); do
  curl -sS -o /dev/null -w '%{http_code} ' https://api.example.com/v1/orders
done; echo

Python (requests)

import requests

# requests does not raise on 500; you have to check for it.
resp = requests.get("https://api.example.com/v1/orders", timeout=10)
print(resp.status_code, resp.headers.get("Server"))

if resp.status_code == 500:
    # Truncate: a leaky server may be handing you a full stack trace.
    print("error body:", resp.text[:400])
    resp.raise_for_status()  # raises requests.exceptions.HTTPError

Node (fetch)

// fetch() resolves normally on 500 — response.ok is the only signal.
const res = await fetch("https://api.example.com/v1/orders");

if (res.status === 500) {
  console.error("server error from", res.headers.get("server"));
  console.error((await res.text()).slice(0, 400));
} else if (!res.ok) {
  console.error("other failure:", res.status);
} else {
  console.log(await res.json());
}

Reference and tooling

The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.

Other 5xx codes