HTTPステータスコードとリダイレクトを curl で確かめる
HTTP講座2。php -S のルーターで 2xx〜5xx を返し、301/302/303 と 307/308 で POST がどうなるかを curl -L で確認。404 と 410、200 で返る致命的エラー、ループの実測。
シリーズ:HTTP講座 2/6(初級)。 第1回を終え、API が何を返すべきか決める立場になった人や、「リダイレクトしたら POST のデータが消えた」と相談された人向けです。読み終えると、よく使うステータスコードを全部返す小さなルーターを自分で書き、301・302・303・307・308 を curl が POST でどう辿るかを自分の目で確認できています。
ステータスコードは雑学に見えますが、1つ間違えると丸1日つぶれます。問い合わせフォームが知らないうちに GET に変わる、旧 URL のリダイレクトが輪になる、PHP が落ちているのに 200 を返すので監視が「正常」と報告する。この講座ではそれぞれを手元で再現し、後でログを見たときにすぐ気づけるようにします。すべて Apple Silicon Mac(M1 Pro)の PHP 8.5.6(php -S)と curl 8.7.1 で実行し、パスは ~/demo/ に置き換えています。
動作確認用のルーターを書く
php -S は PHP の開発用サーバーです。ルータースクリプト を渡すと、すべてのリクエストがそのスクリプトに届くので、好きなステータスコードを返せます。~/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() がステータスを、header() がヘッダーを設定します。どちらも出力より 前 に呼ぶ必要があります(守らないと何が起きるかは第3回)。以下の出力は短くするため、意味がある場合を除いて Date、Host、Connection、X-Powered-By の行を省いています。
2xx:成功にも種類がある
成功は 200 だけではありません。REST 風のリソースでは次の3つをよく使います。
$ 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 は新しいリソースができたこと、
Locationはその場所を示します。クライアントに新しい ID を推測させない作りです。 - 204 No Content は「成功、本文は意図して空」。本文を必ず
JSON.parseするクライアントはここで落ちるので、先にコードを見ます。
全文表示には癖が2つ見えます。php -S は レスポンス に Host ヘッダーを付けます(本番のサーバーは付けません)。また JSON を返しているのに Content-type は既定の text/html のままです。実際の API では Content-Type: application/json を自分で設定します(第3回)。
4xx:クライアント側の誤り
$ 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
初心者が取り違えやすい組み合わせです。
- 401 と 403。 401 は「あなたが誰か分からない」。ログインするかトークンを送れ、という意味で、
WWW-Authenticateを付けて返すのが決まりです。403 は「誰かは分かったが、だめ」。同じトークンで再試行しても結果は変わりません。 - 404 と 410。 どちらも「ない」。410 は「意図して削除した、もう戻らない」が加わります。
- 405 は URL は存在するがそのメソッドでは使えない、という意味で、
Allowに使えるメソッドが並びます。ここで 404 を返すと、相手は URL の打ち間違いを探し始めてしまいます。
他によく見る 4xx:400(壊れた JSON など不正なリクエスト)、409(ユーザー名の重複などの競合)、422(形式は正しいが内容が不正)、429(リクエストが多すぎる。通常 Retry-After 付き)。
5xx と「200 で返る致命的エラー」
/boom は誰も捕まえない例外を投げます。Homebrew の既定設定(display_errors がオン)での実際のレスポンスです。
$ 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 />
よくある間違い:「ステータスコードを見ればページが動いたか分かる」。 実際には、致命的エラーが 200 OK の本文に出て、サーバーのパスまで訪問者全員に見せています。curl -f のヘルスチェックは通り、アプリは HTML を JSON として読もうとして失敗し、検索エンジンがエラー文をインデックスするかもしれません。対策: 自分のマシン以外では display_errors を切り、エラーはログに出します。php -d display_errors=0 -d log_errors=1 -S 127.0.0.1:18103 router.php で起動した2台目のサーバーに同じリクエストを送ると、こうなりました。
$ 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
エラーは本来あるべきサーバーログへ出ました。
[Thu Nov 12 14:30:07 2026] PHP Fatal error: Uncaught RuntimeException: db down in ~/demo/router.php:38
(500 が HTTP/1.0 で返っているのも php -S の癖です。)計画メンテナンスなら、503 Service Unavailable と Retry-After で「後で来て」とクライアントやクローラーに伝えます。
$ 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 に POST を送る
リダイレクトは 3xx のステータスと Location ヘッダーの組です。curl は表示するだけで、-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
面白いのは POST を送ったときです。同じフォーム本文を各リダイレクトに送り、/echo に届いたものを表示させました。
$ 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 で見ると、切り替わる瞬間が分かります。
> 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
301 でも同じでした(* Switch from POST to GET の後に > GET /new)。
| コード | 意味 | リダイレクト後のメソッド(curl -L・ブラウザ) | 主な用途 |
|---|---|---|---|
| 301 | 恒久的に移動 | POST が GET になる | 旧 URL → 新 URL、ページの http → https |
| 302 | 一時的に移動 | POST が GET になる | 短期間の迂回 |
| 303 | 他を見よ | 常に GET | フォーム POST の後(PRG パターン) |
| 307 | 一時的、メソッド維持 | 変わらない | API エンドポイントの一時移動 |
| 308 | 恒久的、メソッド維持 | 変わらない | API エンドポイントの恒久移動 |
歴史的な経緯があります。HTTP/1.0 は 301/302 でメソッドを変えることを認めていませんでしたが、ブラウザは POST を GET に変えてしまい、後の仕様がそれを追認しました。HTTP/1.1 で「意図して GET させる」303 と「メソッドを保つ」307 が加わり、308(RFC 7538、2015年)は 307 の恒久版です。旧 API パスに POST するスマホアプリにとっては、動くか、本文が黙って消えるかの違いになります。Dart クライアントでの実例は Dart の http で POST のリダイレクトが追われない にまとめています。
日本の業務システムでは「問い合わせフォーム送信 → 完了ページ」で、完了ページをリロードすると二重送信になる、という相談がよくあります。POST の後に 303 で完了ページへ送れば、リロードしても GET が繰り返されるだけです。これが PRG(Post/Redirect/Get)パターンです。
よくある間違い:-X POST でメソッドを強制する。 実際には、curl は POST という語だけ残して本文は捨てます。
$ curl -sL -X POST -d 'name=mina' http://127.0.0.1:18102/temp
POST body=[]
-v では * Switch from POST to GET の直後に、Content-Length も本文もない > POST /echo HTTP/1.1 が送られていました。普通のクライアントが絶対に送らないリクエストです。対策: -d と -X を一緒に使わないこと。302 の後も本当に POST し直したいなら --post302(または --post301、--post303)を使います。こちらは本文も保たれ、POST body=[name=mina] でした。
ページを消すとき:301・404・410 の判断表
| 状況 | 返すもの | 理由 |
|---|---|---|
| 内容が新しい URL に移った | 301(ページ)、308(POST を受ける API) | クライアントも検索エンジンも新 URL に移る |
| 一時的に別の場所にある | 302・307 | 旧 URL が正式なまま |
| 意図して削除、代わりなし | 410 | 「もう来ないで」を明示 |
| 知らない URL・打ち間違い・元から存在しない | 404 | 正直な既定値 |
| 削除したのでトップページへ飛ばす | 避ける | 検索エンジンはソフト 404 と見なしがちで、利用者も戸惑う |
| メンテナンス中 | 503 + Retry-After |
ページ自体の誤りではない |
検索エンジンについては、Google のドキュメントは 429 以外の 4xx をインデックス上は同じように扱う(URL がインデックスから外れる)としています。Google の担当者は 410 のほうが少し早く処理されることがあると発言していますが、私たちは測っていません。
リダイレクトループとタイムアウト
互いを指し合う2つのルートです。
$ 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 の既定の上限は 50 回で、ブラウザはもっと早く諦めて「リダイレクトが多すぎます」と表示します(今回は未実行)。実際のループは、2つのルールの食い違いから生まれることがほとんどです。たとえばアプリが https を強制しているのに、前段のプロキシがアプリに http で話しかけている場合や、www ありとなしのルールが互いを向いている場合です。curl -sIL URL | grep -i -E '^(HTTP|location)' でホップを1つずつ表示できます。
もう1つの失敗は、いつまでも答えないサーバーです。/slow は3秒眠ります。
$ curl -sS --max-time 1 http://127.0.0.1:18102/slow
curl: (28) Operation timed out after 1001 milliseconds with 0 bytes received
--max-time がないと、サーバーが接続を開けている限り curl は待ち続けます。スクリプトでは必ず付けます。
実測:リダイレクト1回のコスト
/hop/N は N 回リダイレクトしてから答えます。チェーンの長さごとにウォームアップ1回の後30回、curl -sL -w '%{time_total}' の中央値です(127.0.0.1)。
ローカルでも1ホップ約 0.23 ms 増えます。ホップのたびに完全に新しいリクエストになり、php -S は毎回接続を閉じるので TCP 接続も作り直しだからです。インターネット越しなら1ホップごとに少なくとも1往復かかります。当サイトに5回ずつリクエストした結果です。
| リクエスト | 5回の中央値 | 範囲 |
|---|---|---|
https://tlaboratory.net/ に直接 |
62.1 ms | 59.4〜183.1 ms |
http://tlaboratory.net/ を -L で(https へ1回リダイレクト) |
124.6 ms | 99.3〜174.5 ms |
HTTP→HTTPS のリダイレクト1回で、最初のリクエストの時間が約2倍になりました。リンクは最終 URL に直接張り、http:// → https:// → https://www. → /new-path/ のような連鎖は避けます。
正直な限界・バージョンについて
- ステータスコードとリダイレクトの意味は現在 RFC 9110(2022年6月)にあります。303 と 307 は HTTP/1.1(1997年の RFC 2068、1999年の RFC 2616)で導入され、308 は RFC 7538(2015年)で、現在は RFC 9110 に含まれています。
- POST→GET の切り替えは curl 8.7.1 で確認したものです。ブラウザも 301/302/303 では同じく GET に変え、307/308 ではメソッドを保ちますが、ブラウザは実行していません。ブラウザは 301 と 308 を長くキャッシュすることがあり、curl は何もキャッシュしないので、「直した」リダイレクトがブラウザでは残って見えることがあります。
php -S(8.5.6)はシングルプロセスで、レスポンスごとに接続を閉じます。レスポンスにHostヘッダーを付け、500 をHTTP/1.0で返しました。Apache、nginx、LiteSpeed はこの辺りの細部が違います。display_errorsがオンだったのは、使った Homebrew のphp.iniが開発用だったためです。サーバーの既定値は環境で違うのでphp -i | grep display_errorsで確認してください。- tlaboratory.net の時間は1つの回線からの5回分です。
演習
1. スマホアプリが /api/v1/save に JSON を POST しています。エンドポイントを /api/v2/save に移すとき、旧パスからは何を返しますか? 301 ではいけない理由も答えてください。
2. 監視が毎分 curl -s https://example.test/health を実行し、終了コードが 0 以外ならアラートを出します。1時間 PHP の致命的エラーが表示されていたのに、一度も鳴りませんでした。理由を2つと、直したコマンドを答えてください。
解答
1. 308 Permanent Redirect(一時的なら 307)。301 だと、リダイレクトを追うクライアントは上の /old と同じく POST を GET に変えて本文を捨てます。308 ならメソッドと本文が残りました(POST body=[name=mina])。なお、旧バージョンのアプリは POST のリダイレクト自体を追わないこともあるので、サーバー内部のリライトのほうが安全な場合が多いです。
2. (a) -f がないと curl はどの HTTP ステータスでも終了コード 0。(b) display_errors がオンで、致命的エラーがステータス 200 のまま送られていた。両方直します。サーバーで display_errors=0(落ちたら 500 になる)にし、監視は curl -fsS --max-time 10 https://example.test/health にします。4xx/5xx で 22、応答なしで 28 になります。
次へ
- 前回:HTTP講座 1/6:リクエスト・レスポンスと curl -v
- 次回:HTTP講座 3/6:ヘッダー・Cookie・セッション
- その後:ETag と Cache-Control のキャッシュ、HTTPS と TLS、keep-alive と圧縮
- CDN がリダイレクトをキャッシュする話:CDN が API レスポンスとリダイレクトをキャッシュする
- 非 ASCII の URL でリライトが壊れる話:LiteSpeed の .htaccess リライトと非 ASCII URL
- PHP 側から見た
php -S:PHP の関数・フォーム・ビルトインサーバー - すべての講座:プログラミング学習ロードマップ
よくある質問
リダイレクトの後で POST が GET になってしまうのはなぜですか?
301・302・303 ではクライアントが POST を GET に変えて本文を捨てるからです。curl -L は Switch from POST to GET と表示し、転送先には GET body=[] が届きました。307・308 ではメソッドと本文が保たれ、POST body=[name=mina] が届きました。POST をリダイレクトで生かしたいなら 307 か 308、フォーム送信後に意図して GET させるなら 303 を使います。
削除したページは 404 と 410 のどちらを返すべきですか?
どちらも「ここにはない」という意味です。410 Gone は「意図して消した、もう戻らない」と明示します。404 は「今ここに何もない」だけです。内容が移動しただけなら、どちらでもなく新しい URL への 301 を返します。知らない URL への既定の応答は 404 で構いません。
PHP のページが落ちているのに curl では 200 OK と出ます。なぜですか?
display_errors がオンだと、PHP は致命的エラーを本文に出力し、ステータスは 200 のままでした。コードだけを見る監視では成功に見えます。display_errors=0 にすると同じエラーで 500 Internal Server Error が返り、curl -f は終了コード 22 で終わりました。