HTTP 500 Internal Server Error: Causes, Fixes, and Tests

HTTP 500 Internal Server Error means the server hit an unexpected condition and could not complete the request. It is the generic server failure: an unhandled exception, a missing config value, a dead database connection. The request itself was probably fine. Find the request ID, read the server log, fix the code path.
500 is the first of the 5xx HTTP status codes, the group that puts the fault on the server. It is also the least informative one, which is the point of this page: what the response should carry, which 5xx to use instead when you know more, the causes worth checking first, and how to test the failure on purpose rather than waiting for it.
What the RFC says about 500
RFC 9110 section 15.6.1 defines it in one sentence: "The 500 (Internal Server Error) status code indicates that the server encountered an unexpected condition that prevented it from fulfilling the request."
Two things follow. Use 500 only when no more specific 5xx fits, because every alternative tells the caller more. And note what the sentence does not say: it makes no claim about how long the condition lasts. A 500 is not a promise that a retry will work.
The 4xx class, by contrast, means the client appears to have erred (RFC 9110 section 15.5). A validator that crashes on bad input returns 500 until somebody fixes it, but the answer that input deserved was always a 4xx.
What a 500 response must carry
RFC 9110 defines no status-specific header for 500. Send the real media type of the body and stop there. Retry-After is optional advisory information, an HTTP date or a number of seconds (RFC 9110 section 10.2.3). The RFC calls it out for 503 and for redirects, not as something every 500 should carry. Send it on a 500 only when the server genuinely knows when a transient failure clears.
Cache-Control: no-store is the safe default for an API error body, since it carries a request ID and the failure may clear immediately. That is an implementation recommendation drawn from RFC 9111 section 3, not a rule of the status code. A 500 is not heuristically cacheable either: RFC 9110 section 15.1 lists 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, and 501, and 500 is not among them.
The body matters more than the headers. Give it a stable machine-readable code and a request ID an operator can search for. Give it no stack trace, no SQL, no file path, and no secret. Express omits stack traces in production for exactly this reason. The format below is problem details, the JSON error shape from RFC 9457, with requestId added as an extension member.
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
Cache-Control: no-store
{
"type": "https://api.example.com/problems/internal-error",
"title": "Internal Server Error",
"status": 500,
"detail": "The server could not complete the request.",
"requestId": "req_01J6Y8F4M9X2"
}
HTTP 500 vs 502, 503, 504, and 4xx
| Code | Meaning | Boundary | Source |
|---|---|---|---|
| 500 | An unexpected condition inside the server that answered | Nothing more specific fits | RFC 9110 section 15.6.1 |
| 502 Bad Gateway | A gateway or proxy received an invalid response from upstream | Upstream answered, badly | RFC 9110 section 15.6.3 |
| 503 Service Unavailable | Temporarily unable to handle the request, from overload or scheduled maintenance. May carry Retry-After | A known temporary state, so prefer it over a generic 500 | RFC 9110 section 15.6.4 |
| 504 Gateway Timeout | A gateway or proxy received no timely response from upstream | Upstream did not answer in time | RFC 9110 section 15.6.5 |
| 400 and 422 | Client error: malformed input, or valid syntax that cannot be processed | The answer bad input deserves. A crash on bad input is a bug that surfaces as a 500 | RFC 9110 sections 15.5 and 15.5.21 |
What causes an HTTP 500 and how to fix it
| Cause | Fix |
|---|---|
| An unhandled application exception | Reproduce the input, find the trace by request ID, fix the throwing path, and keep the public body generic. |
| Missing or invalid configuration | Validate required environment values at startup, and restore the value before the instance accepts traffic again. |
| Out of memory | Check memory telemetry and the kill reason. Fix the leak or raise the correct limit. Restarting is recovery, not a fix. |
| File or directory permissions | Grant the service account access to what it needs and nothing more. Do not make the tree world-writable. |
| A database or origin failure behind Cloudflare | Check origin logs and database connectivity. If the HTML body mentions cloudflare or cloudflare-nginx, send Support the domain, the time with its timezone, and the trace output. |
The first four rows follow MDN's 500 reference, the last one Cloudflare's. If the same endpoint keeps producing them, the pattern is usually one of the common API failures rather than a fresh bug each time.
Express
Error middleware takes four arguments and goes after all the routes. Log internally, answer generically, and hand off to the default handler when the headers have already gone out. Express 5 forwards rejected promises to this handler on its own, so an async route no longer needs its own try-catch just to reach it.
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
console.error(req.id, err);
res.status(500).type('application/problem+json').json({
type: 'https://api.example.com/problems/internal-error',
title: 'Internal Server Error',
status: 500,
detail: 'The server could not complete the request.',
requestId: req.id,
});
});
FastAPI
The work here is preventing a 500, not formatting one. Expected bad input should never reach the generic handler. Raise HTTPException with the right 4xx, keep the built-in RequestValidationError handling, and add an @app.exception_handler only for an application exception that needs a stable response of its own.
if order.quantity <= 0:
raise HTTPException(status_code=422, detail="quantity must be positive")
Spring MVC
Put an @ExceptionHandler in the controller, or in a @ControllerAdvice when several controllers need the same treatment. That gives you a stable body and the status you intended while the underlying dependency is repaired. Spring's own reference maps an IOException to ResponseEntity.internalServerError().
@ExceptionHandler(IOException.class)
ResponseEntity<ProblemDetail> handleIOException(IOException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"The server could not complete the request."
);
return ResponseEntity.internalServerError().body(problem);
}
Cloudflare
A plain 500 through Cloudflare usually points at the origin, so start with origin logs, hosting, the database, and the application. A body that contains cloudflare or cloudflare-nginx points somewhere else, and Cloudflare's documentation sends that case to Support with the domain, the exact time and timezone, and the trace output.
Should a client retry a 500?
Treat the operation as failed. Show a neutral message. Never render the raw body to a user.
Record the request ID. It is the only thing that connects what the user saw to the server log that explains it.
Do not ask the user to change valid input. This is not a validation error, and telling them it might be sends them in circles.
Retry only safe or idempotent operations. GET, HEAD, PUT, and DELETE qualify, meaning a repeat leaves the same result as a single call. Bound the attempts, and honor
Retry-Afterif the server sent one. HTTP methods covers which verbs are idempotent and why.Never auto-replay a payment or an order. Not without an idempotency key, or proof that the first attempt was not applied.
Those rules come out of RFC 9110 section 9.2.2 on idempotent methods and section 10.2.3 on Retry-After.
How to test a 500 response
Make the failure deterministic with a test-only fault switch or a stubbed dependency, then assert the whole contract: status, media type, a stable problem type, a request ID, the cache policy, and the absence of anything internal. That last assertion is the one that catches a debug flag left on in staging. The rest of API testing applies as usual.
import { test, expect } from '@playwright/test';
test('returns a safe problem+json 500 for an injected failure', async ({ request }) => {
const response = await request.get('/__test__/force-500', {
headers: { 'x-test-fault': 'database' },
});
expect(response.status()).toBe(500);
expect(response.headers()['content-type']).toContain('application/problem+json');
expect(response.headers()['cache-control']).toContain('no-store');
const problem = await response.json();
expect(problem).toMatchObject({
type: 'https://api.example.com/problems/internal-error',
status: 500,
requestId: expect.any(String),
});
expect(JSON.stringify(problem)).not.toMatch(/stack|sql|password|secret/i);
});
Two ways this goes wrong. One is calling a real shared database and hoping it fails, which passes or fails on chance. The other is quieter: a suite that fails on the first 500, retries, and passes on the next 200. Playwright calls a test that fails and then passes on retry flaky, and that label is the only sign left that the endpoint broke. Replace chance with an injected failure, and report the first unexpected 500 rather than hiding it behind a retry. The wider treatment is in flaky tests.
How Qodex flags a 500
Qodex runs its tests against the preview build on every pull request, and classifies each failure as a real bug, a stale test with a proposed repair diff, or an environment issue. Every real bug arrives with the failing request, the response, and a screenshot, and saved scenarios replay deterministically with no model call. A 500 on a pull request therefore comes back as a reproduction rather than a log search. See how Qodex API testing works.
Related status codes
HTTP 503 Service Unavailable. The code to use when the outage is known and temporary.
HTTP 422 Unprocessable Content. What bad input should return instead of a crash.
HTTP 429 Too Many Requests. The limit that clears with time.
HTTP 401 vs 403. Identity versus permission.
HTTP status codes. The full list, 1xx to 5xx.
API testing. What to assert and how to run it in CI.
Frequently Asked Questions
Is HTTP 500 a client error or a server error?
A server error. RFC 9110 puts it in the 5xx class, which means the server encountered an unexpected condition it could not recover from. The request that triggered it was usually valid.
Is HTTP 500 safe to retry?
Only for idempotent operations such as GET, HEAD, PUT, and DELETE, and only with a bounded number of attempts. Do not retry a POST automatically unless an idempotency key makes the replay safe.
What is the difference between HTTP 500 and 502?
500 is a failure inside the server that answered you. 502 is a gateway or proxy reporting that the server behind it returned an invalid response. The 502 tells you which hop to inspect first.
What is the difference between HTTP 500 and 503?
500 is unexpected and says nothing about how long it will last. 503 describes a known temporary state, either overload or scheduled maintenance, and it may carry a Retry-After header with an estimate.
Why should invalid input return 400 or 422 instead of 500?
Because a client can act on a 4xx and cannot act on a 5xx. A 500 raised by bad input means a validation path went unhandled, which is a bug on the server side, not a client problem.
What should a JSON 500 response include?
A stable problem type, a status of 500, a neutral detail string, and a request ID an operator can search for. Never a stack trace, a SQL statement, a file path, or a secret.
Can an HTTP 500 response be cached?
Not by default. It is absent from the heuristically cacheable list in RFC 9110, so a cache stores it only with explicit directives, which an API error response should not be sending anyway.
How can I test a 500 response without breaking production?
Add a test-only fault switch, or stub the dependency in a test environment, force the failure on purpose, and assert the response shape. Waiting for a real outage is not a test strategy.




