HTTP Status Codes and Redirects, Tested with curl

HTTP lecture 2: a php -S router for 2xx-5xx, POST under 301/302/303 vs 307/308 with curl -L, 404 vs 410, a crash sent as 200, redirect costs.

Series: HTTP lecture 2/6 (beginner). For people who finished lecture 1 and now have to decide what an endpoint should return, or why a client "lost" its POST data after a redirect. By the end you will have written a small router that returns every common status code, and you will have watched curl follow 301, 302, 303, 307 and 308 with a POST.

Status codes look like trivia until one of them costs you a day: a login form that silently turns into a GET, an old URL that redirects in a circle, or a crashed PHP page that monitoring reports as healthy because it returned 200. This lecture runs each of those cases on your own machine so you can recognise them in logs later. Everything was run with PHP 8.5.6 (php -S) and curl 8.7.1 on an Apple Silicon Mac (M1 Pro); paths are shortened to ~/demo/.

A router you can test against

php -S is PHP's development server. Given a router script, it sends every request to that script, which lets us return any status code we want. Save this as ~/demo/router.php:

<?php
// php -S 127.0.0.1:18102 router.php
$path   = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];

function redirect(int $code, string $to): void {
    http_response_code($code);
    header("Location: $to");
}

if (preg_match('#^/hop/(\d+)$#', $path, $m)) {      // /hop/3 -> /hop/2 -> ... -> /hop/0
    $n = (int)$m[1];
    if ($n > 0) { redirect(301, '/hop/' . ($n - 1)); } else { echo "arrived\n"; }
    exit;
}

switch ($path) {
    case '/':        echo "home\n"; break;
    case '/old':     redirect(301, '/new'); break;    // moved for good
    case '/temp':    redirect(302, '/echo'); break;   // temporary
    case '/see':     redirect(303, '/echo'); break;   // "go GET this"
    case '/t307':    redirect(307, '/echo'); break;   // temporary, keep method
    case '/p308':    redirect(308, '/echo'); break;   // permanent, keep method
    case '/new':     echo "new page\n"; break;
    case '/echo':    echo "$method body=[" . file_get_contents('php://input') . "]\n"; break;
    case '/loop-a':  redirect(302, '/loop-b'); break;
    case '/loop-b':  redirect(302, '/loop-a'); break;
    case '/gone':    http_response_code(410); echo "deleted on purpose\n"; break;
    case '/items':
        if ($method !== 'POST') { http_response_code(405); header('Allow: POST'); break; }
        http_response_code(201); header('Location: /items/42'); echo "{\"id\":42}\n"; break;
    case '/items/42':
        if ($method === 'DELETE') { http_response_code(204); break; }
        echo "{\"id\":42}\n"; break;
    case '/private': http_response_code(401); header('WWW-Authenticate: Bearer'); break;
    case '/admin':   http_response_code(403); echo "forbidden\n"; break;
    case '/busy':    http_response_code(503); header('Retry-After: 30'); echo "maintenance\n"; break;
    case '/boom':    throw new RuntimeException('db down');
    case '/slow':    sleep(3); echo "finally\n"; break;
    default:         http_response_code(404); echo "not found: $path\n";
}
cd ~/demo && php -S 127.0.0.1:18102 router.php

http_response_code() sets the status, header() adds a header. Both must run before any output; lecture 3 shows what happens otherwise. In the outputs below we removed the Date, Host, Connection and X-Powered-By lines to keep them short, except where they matter.

2xx: it worked, but how?

200 is not the only success. A typical REST resource uses three:

$ curl -si -X POST -d '{"name":"a"}' -H 'Content-Type: application/json' http://127.0.0.1:18102/items
HTTP/1.1 201 Created
Host: 127.0.0.1:18102
Date: Thu, 12 Nov 2026 14:29:26 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Location: /items/42
Content-type: text/html; charset=UTF-8

{"id":42}

$ curl -si -X DELETE http://127.0.0.1:18102/items/42 | head -1
HTTP/1.1 204 No Content
  • 201 Created says a new resource exists, and Location says where. A client should not have to guess the new id.
  • 204 No Content means success with an intentionally empty body. A client that always calls JSON.parse on the body will crash on it; check the code first.

Two quirks are visible in the full output: php -S sends a Host header in the response (real servers do not), and it forgot to change Content-type from its text/html default although we sent JSON. Real APIs must set Content-Type: application/json themselves (lecture 3).

4xx: the client asked wrong

$ curl -si http://127.0.0.1:18102/nope
HTTP/1.1 404 Not Found
Content-type: text/html; charset=UTF-8

not found: /nope

$ curl -si http://127.0.0.1:18102/gone
HTTP/1.1 410 Gone
Content-type: text/html; charset=UTF-8

deleted on purpose

$ curl -si http://127.0.0.1:18102/private
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-type: text/html; charset=UTF-8

$ curl -si http://127.0.0.1:18102/admin
HTTP/1.1 403 Forbidden
Content-type: text/html; charset=UTF-8

forbidden

$ curl -si http://127.0.0.1:18102/items | grep -E 'HTTP|Allow'
HTTP/1.1 405 Method Not Allowed
Allow: POST

The pairs beginners mix up:

  • 401 vs 403. 401 means "I don't know who you are": log in or send a token. It should come with WWW-Authenticate. 403 means "I know who you are, and the answer is no". Retrying with the same token will not help.
  • 404 vs 410. Both say "not here". 410 adds "removed on purpose, will not come back".
  • 405 means the URL exists but not for this method, and Allow lists the methods that work. Returning 404 here sends people looking for a typo in the URL.

Other 4xx you will meet: 400 (malformed request, e.g. invalid JSON), 409 (conflict, e.g. duplicate username), 422 (well-formed but invalid data), 429 (too many requests, usually with Retry-After).

5xx and the crash that returned 200

/boom throws an exception that nobody catches. This is the real response with Homebrew's default settings (display_errors on):

$ curl -si http://127.0.0.1:18102/boom
HTTP/1.1 200 OK
Host: 127.0.0.1:18102
Date: Thu, 12 Nov 2026 14:32:09 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Content-type: text/html; charset=UTF-8

<br />
<b>Fatal error</b>:  Uncaught RuntimeException: db down in ~/demo/router.php:38
Stack trace:
#0 {main}
  thrown in <b>~/demo/router.php</b> on line <b>38</b><br />

Common mistake: "the status code tells me whether the page worked." What actually happens: a fatal error printed into a 200 OK, plus your server path handed to every visitor. A health check like curl -f passes, an app tries to parse HTML as JSON, and a search engine may index the error text. Fix: turn display_errors off outside your own machine and log errors instead. The same request on a second server started with php -d display_errors=0 -d log_errors=1 -S 127.0.0.1:18103 router.php:

$ curl -si http://127.0.0.1:18103/boom | head -1
HTTP/1.0 500 Internal Server Error
$ curl -fsS http://127.0.0.1:18103/boom; echo "exit=$?"
curl: (22) The requested URL returned error: 500
exit=22

and the error went to the server log, where it belongs:

[Thu Nov 12 14:30:07 2026] PHP Fatal error:  Uncaught RuntimeException: db down in ~/demo/router.php:38

(Note the HTTP/1.0 on the 500: another php -S quirk.) For planned downtime, 503 Service Unavailable with Retry-After tells clients and crawlers to come back:

$ curl -si http://127.0.0.1:18102/busy
HTTP/1.1 503 Service Unavailable
Retry-After: 30
Content-type: text/html; charset=UTF-8

maintenance

3xx: 301, 302, 303, 307, 308 with a POST

A redirect is a 3xx status plus a Location header. curl prints it but does not follow unless you add -L:

$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:18102/old
301 http://127.0.0.1:18102/new

The interesting part is what happens to a POST. We sent the same form body to each redirect and let /echo print what arrived:

$ for p in /old /temp /see /t307 /p308; do curl -sL -d 'name=mina' http://127.0.0.1:18102$p -w 'final=%{http_code} redirects=%{num_redirects}\n'; done
new page
final=200 redirects=1
GET body=[]
final=200 redirects=1
GET body=[]
final=200 redirects=1
POST body=[name=mina]
final=200 redirects=1
POST body=[name=mina]
final=200 redirects=1

-v shows the moment it happens:

> POST /temp HTTP/1.1
< HTTP/1.1 302 Found
< Location: /echo
* Issue another request to this URL: 'http://127.0.0.1:18102/echo'
* Switch from POST to GET
> GET /echo HTTP/1.1

The 301 case did the same (* Switch from POST to GET, then > GET /new).

Decision diagram. A POST with the body name=mina is sent and the server answers with a redirect. If the code is 301, 302 or 303, curl -L switched to GET and sent no body, so the target printed GET body=[]. If the code is 307 or 308, curl kept the method and the body, and the target printed POST body=[name=mina].
What curl -L did with a POST after each redirect code (curl 8.7.1, php -S). Browsers behave the same way for 301/302/303 in practice, but we did not run a browser.
Code Meaning Method after redirect (curl -L, browsers) Typical use
301 moved permanently POST becomes GET old URL → new URL, http → https for pages
302 found, temporary POST becomes GET short-lived detours
303 see other always GET after a form POST (Post/Redirect/GET)
307 temporary, keep method unchanged temporary move of an API endpoint
308 permanent, keep method unchanged permanent move of an API endpoint

The historic reason: HTTP/1.0 did not allow clients to change the method on 301/302, browsers changed POST to GET anyway, and later specs accepted that. HTTP/1.1 added 303 to mean "GET this on purpose" and 307 to mean "keep the method"; 308 (RFC 7538, 2015) is the permanent version of 307. For a mobile app that POSTs to an old API path, this is the difference between working and silently losing the body; the Dart client case is in Dart http: POST redirect not followed.

Common mistake: forcing the method with -X POST. What actually happens: curl keeps the word POST but still drops the body.

$ curl -sL -X POST -d 'name=mina' http://127.0.0.1:18102/temp
POST body=[]

-v showed * Switch from POST to GET followed by > POST /echo HTTP/1.1 with no Content-Length and no body: a request no real client sends. Fix: do not use -X with -d. If you really want curl to re-POST after a 302, use --post302 (or --post301, --post303), which kept the body: POST body=[name=mina].

Removing a page: 301, 404 or 410?

Situation Return Why
Content moved to a new URL 301 (pages) or 308 (APIs taking POST) clients and search engines move to the new URL
Content temporarily elsewhere 302 / 307 the old URL stays the real one
Deleted on purpose, no replacement 410 says "do not come back" explicitly
Unknown URL, typos, never existed 404 the honest default
Deleted, but you redirect to the home page avoid search engines tend to treat it as a "soft 404", users get confused
Down for maintenance 503 + Retry-After not an error of the page itself

For search engines, Google's documentation treats 4xx codes (except 429) alike: the URL drops out of the index. Google staff have said a 410 may be processed a little faster; we did not measure that.

Redirect loops and timeouts

Two routes pointing at each other:

$ curl -sSL --max-redirs 5 http://127.0.0.1:18102/loop-a -o /dev/null; echo "exit=$?"
curl: (47) Maximum (5) redirects followed
exit=47
$ curl -sSL http://127.0.0.1:18102/loop-a -o /dev/null -w 'redirects=%{num_redirects}\n'
curl: (47) Maximum (50) redirects followed
redirects=50

curl's default limit is 50; browsers give up much earlier and show "too many redirects" (not run here). Real loops usually come from two rules disagreeing, for example the application forcing https while a proxy in front talks to it over http, or www and non-www rules pointing at each other. curl -sIL URL | grep -i -E '^(HTTP|location)' prints the chain hop by hop.

A server that never answers is the other failure. /slow sleeps 3 seconds:

$ curl -sS --max-time 1 http://127.0.0.1:18102/slow
curl: (28) Operation timed out after 1001 milliseconds with 0 bytes received

Without --max-time, curl waits as long as the server keeps the connection open. Always set it in scripts.

Measured: what a redirect costs

/hop/N redirects N times before answering. 30 runs per chain length after one warm-up, curl -sL -w '%{time_total}', median, on 127.0.0.1:

Bar chart of median times on localhost: no redirect 0.48 ms, 1 redirect 0.74 ms, 3 redirects 1.20 ms, 5 redirects 1.60 ms, 10 redirects 2.76 ms. Each extra hop added roughly a quarter of a millisecond.
Median total time of curl -L through a chain of 301 redirects on php -S at 127.0.0.1 (30 runs each after 1 warm-up, Apple M1 Pro). Each hop is a new request and a new connection; on the real internet one HTTP→HTTPS hop doubled the time of a request to our own site.

About 0.23 ms per hop locally, because each hop is a full new request and, with php -S closing every connection, a new TCP connection too. On the internet each hop costs at least one round trip. Five requests to our own site gave:

Request Median of 5 Range
https://tlaboratory.net/ directly 62.1 ms 59.4–183.1 ms
http://tlaboratory.net/ with -L (1 redirect to https) 124.6 ms 99.3–174.5 ms

One HTTP→HTTPS redirect doubled the time of the first request. Link to the final URL directly, and avoid chains like http:// → https:// → https://www. → /new-path/.

Honest limits and version notes

  • Status codes and redirect semantics are now in RFC 9110 (June 2022). 303 and 307 came with HTTP/1.1 (RFC 2068 in 1997 and RFC 2616 in 1999); 308 was RFC 7538 (2015) and is now part of RFC 9110.
  • The POST→GET switch was observed in curl 8.7.1. Browsers do the same for 301/302/303 and keep the method for 307/308, but we did not run a browser. Browsers also cache 301 and 308 redirects, sometimes for a long time; curl does not cache anything, so a "fixed" redirect can still appear in your browser.
  • php -S (8.5.6) is single-process and closes the connection after every response; it also adds a Host response header and answered the 500 with HTTP/1.0. Apache, nginx and LiteSpeed behave differently in these details.
  • display_errors was on because the Homebrew php.ini we used is the development one. Your server's default may differ; check with php -i | grep display_errors.
  • The tlaboratory.net timings are 5 samples from one network; your numbers will differ.

Exercises

1. A mobile app POSTs JSON to /api/v1/save. You move the endpoint to /api/v2/save. Which status code do you return from the old path, and why not 301?

2. Your monitoring runs curl -s https://example.test/health every minute and alerts on non-zero exit codes. It never fired, although the page showed a PHP fatal error for an hour. Give two reasons and the fixed command.

3. Without running it: what does curl -s -o /dev/null -w '%{http_code} %{num_redirects}\n' http://127.0.0.1:18102/hop/3 print, and what changes with -L?

Answers

1. 308 Permanent Redirect (or 307 if it is temporary). With 301, clients that follow the redirect switch POST to GET and drop the body, as /old did above; with 308 the method and body survived (POST body=[name=mina]). Also note that old app versions may not follow redirects for POST at all, so an internal rewrite on the server is often safer than any redirect.

2. (a) Without -f, curl exits 0 for any HTTP status. (b) With display_errors on, the fatal error was sent with status 200 anyway. Fix both: display_errors=0 on the server (so a crash becomes 500), and curl -fsS --max-time 10 https://example.test/health, which exits 22 on 4xx/5xx and 28 on a hang.

3. 301 0: curl stops at the first response. With -L it prints 200 3 and the body arrived goes to /dev/null.

Next

FAQ

Why did my POST become a GET after a redirect?

With 301, 302 and 303, clients switch POST to GET and drop the body. curl -L printed Switch from POST to GET and the target saw GET body=[]. With 307 and 308 the method and body are kept: the target saw POST body=[name=mina]. Use 307 or 308 when a POST must survive a redirect, or 303 on purpose after a form submit.

Should a deleted page return 404 or 410?

Both tell clients the page is not there. 410 Gone says it was removed on purpose and will not come back; 404 only says nothing is here now. If the content moved, neither is right: return a 301 to the new URL. A 404 is fine as the default for unknown URLs.

My PHP page crashed but curl shows 200 OK. Why?

With display_errors on, PHP printed the fatal error into the body and the response kept status 200, so monitoring that checks only the code saw success. With display_errors=0 the same crash returned 500 Internal Server Error and curl -f exited with 22.

  • #http
  • #beginner
  • #status-codes
  • #redirects
  • #curl
  • #php

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑