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)。

判断の図。本文 name=mina 付きの POST を送り、サーバーがリダイレクトで答える。コードが 301・302・303 なら curl -L は GET に切り替えて本文を送らず、転送先は GET body=[] と表示した。307・308 ならメソッドと本文を保ち、転送先は POST body=[name=mina] と表示した。
curl -L が POST をリダイレクトコードごとにどう扱ったか(curl 8.7.1、php -S)。ブラウザも 301/302/303 では実際上同じ動きをするが、ブラウザは実行していない。
コード 意味 リダイレクト後のメソッド(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)。

localhost での中央値の棒グラフ。リダイレクトなし 0.48 ms、1回 0.74 ms、3回 1.20 ms、5回 1.60 ms、10回 2.76 ms。1ホップごとに約 0.25 ms 増えた。
127.0.0.1 の php -S で 301 リダイレクトを連鎖させ、curl -L で辿った合計時間の中央値(ウォームアップ1回の後に各30回、Apple M1 Pro)。1ホップごとに新しいリクエストと新しい接続になる。実際のインターネットでは HTTP→HTTPS の1ホップで当サイトへのリクエスト時間が約2倍になった。

ローカルでも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 になります。

次へ

よくある質問

リダイレクトの後で 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 で終わりました。

  • #HTTP
  • #初級
  • #ステータスコード
  • #リダイレクト
  • #curl
  • #PHP

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑