PHPでHTTPステータスコードを指定する際の小さな落とし穴

この記事は、CYBOZU SUMMER BLOG FES '24 (Garoon Stage) DAY 3の記事です。

こんにちは、サイボウズ Garoon開発 Tsukimiチーム所属の中田です。

本記事では、現在Tsukimiチームが進めているGaroonのインフラ移行プロジェクトにおいて発見した、 PHPでHTTPステータスコードを指定する際の小さな落とし穴について紹介します。

対象

HTTPステータスコードの指定にフレームワークが準備している仕組みではなく、header()http_response_code()を使って指定することがある方。 PHPの内部実装に興味がある方。以下のクイズに面白みを感じる方。などなど

クイズ

早速ですが、PHPでのHTTPステータスコードの指定方法についてクイズを5問出しますので、挑戦してみてください!

php-fpm:8.2.22 + nginx の環境で以下のコードを実行した場合、 どのようなHTTPステータスコードが返ってくると思いますか?

// Q1
<?php
header('HTTP/1.1 200');
header('HTTP/1.1 400');
header('HTTP/1.1 500');
// Q2
<?php
header('HTTP/1.1 200');
http_response_code(400);
http_response_code(500);
// Q3
<?php
http_response_code(200);
header('Content-Type: application/json', true, 400);
http_response_code(500);
// Q4
<?php
header('HTTP/1.1 200');
header('Content-Type: application/json', true, 400);
header('Error-Code: ABC', true, 500);
// Q5
<?php
http_response_code(200);
header('Content-Type: application/json', true, 300);
header('HTTP/1.1 400');
http_response_code(500);

答え

# Q1
curl -i http://localhost/q1.php
HTTP/1.1 500 Internal Server Error

# Q2
curl -i http://localhost/q2.php
HTTP/1.1 200 OK

# Q3
curl -i http://localhost/q3.php
HTTP/1.1 500 Internal Server Error

# Q4
curl -i http://localhost/q4.php
HTTP/1.1 500 Internal Server Error

# Q5
curl -i http://localhost/q5.php
HTTP/1.1 400 Bad Request

さて、どうでしょうか?
意外な結果が返ってきたなと思った方は、是非本記事を読んでみてください!

発端

今回の問題は、Tsukimiチームが進めているインフラの移行プロジェクトにおける非同期処理サービスの移行中に発生しました。

非同期処理サービスとの連携部分では、例えば「(環境起因の実行時エラーのような)500系のエラーが発生した場合はリトライさせる」などの仕様があります。 そのため、HTTPステータスコードを使って処理の成否やリトライの要否を判断しており、HTTPステータスコードの指定が重要な役割を担っています。

何が起こったか

そんな折、リトライ不要なエラーが起こっているにも関わらずリトライを実施しているという報告がありました。由々しき事態です。 確認してみるとhttp_response_code()を利用してステータスコードを指定しているにも関わらず、実際のレスポンスには指定値が反映されていませんでした。 改修に向けて調べた結果、http_response_code()のかわりにheader('HTTP/...')の形式でステータスコードを指定すると、期待通りに振る舞うことがわかりました。

www.php.net

www.php.net

原因調査

発生した問題は解決しましたが、header('HTTP/...')http_response_code()の違いについて疑問が残りました。 ステータスコードの指定方法による振る舞いの差異はないと思っていたのですが、どうやら実際には違いがあるようです。 この差異を確認するため、PHPのソースコードや実際の振る舞いを確認しました。

HTTPステータスコードの指定方法

ここで改めてPHPでHTTPステータスコードを指定する方法を確認しておきます。 今回は以下の3つの方法について調べました。

  1. header('HTTP/....'): header()を使ってヘッダーを指定する際に、RFC 9112に準拠した形式でステータスコードを指定する
  2. header()の第3引数: header()第3引数を使ってステータスコードを指定する
  3. http_response_code(): http_response_code()を使ってステータスコードを指定する

php-srcを確認

HTTPステータスコードの指定方法による振る舞いの違いを確認するため、php-srcを読みます。

github.com

header('HTTP/...')の形式での指定とhttp_response_code()で指定する際の実装を読んでみると、それぞれ受け渡した値の代入先が異なっており、 HTTPステータスコードの保持先として2つの変数が存在していることがわかりました。

また、header()の第3引数を使うとこのコードが呼び出されて、 SG(sapi_headers).http_status_lineが初期化されSG(sapi_headers).http_response_codeが上書きされます。

それぞれの方法で2つの変数をどう操作するかを改めて表にすると以下のようになります。

指定方法 SG(sapi_headers).http_status_line SG(sapi_headers).http_response_code
header('HTTP/...') 更新 何もしない
header()の第3引数 初期化 更新
http_response_code() 何もしない 更新

ヘッダー送信処理の実装を確認

ヘッダーを送信するコードを読んでみると、 SG(sapi_headers).http_status_lineに値が代入されている場合は SG(sapi_headers).http_response_codeよりも優先順位が高くなることがわかりました。

このことからheader()にてすでに HTTP/1.1 200 OKなどとステータスコードを指定していると、http_response_code() よりも優先順位が高くなることがわかります。

実装を読んでみて

HTTPステータスコードを指定する方法によって、小さな違いがあることがわかりました。 安全に開発するには、1つのプロダクトでどれか1つの方法のみを使うようにして、常に後発の指定値が反映されるようにするのが良いでしょう。

逆に問題が発生し得るのは複数の方法を使ってHTTPステータスコードを指定している場合です。 特にheader('HTTP/...')http_response_code()の2種類の方法を1つのプロダクトで使っている場合は、常に後発の指定値が反映されるとは限らないことがわかりました。

試してみる

先ほどのクイズで出した問題について、実際に動かしてHTTPステータスコードの指定がどう反映されるかを確認してみます。

環境

Docker上でPHPとNginxを使って検証を行いました。 詳細についてはDockerfileなどをリポジトリにまとめていますので、ご興味があればご覧ください。

php-fpm: 8.2.22
nginx: 1.26.1

Q1: 1つの指定方法のみ使う

<?php
header('HTTP/1.1 200');
header('HTTP/1.1 400');
header('HTTP/1.1 500');

常にステータスコードが上書きされ、最後に指定した指定値がレスポンスのステータスコードとして反映されました。

curl -i http://localhost/q1.php
HTTP/1.1 500 Internal Server Error

http_response_code()でも同様の結果になりました。

<?php
http_response_code(200);
http_response_code(400);
http_response_code(500);
curl -i http://localhost/q1_1.php
HTTP/1.1 500 Internal Server Error

Q2: header('HTTP/...')とhttp_response_code()を使う

<?php
header('HTTP/1.1 200');
http_response_code(400);
http_response_code(500);

header('HTTP/...')http_response_code()を併用しているため後発が優先されない可能性のあるパターンです。 header('HTTP/...')で指定した値の優先度が高いため、後からhttp_response_code()を使ってもステータスコードに反映されませんでした。

curl -i http://localhost/q2.php
HTTP/1.1 200 OK

Q3: header()の第3引数とhttp_response_code()を使う

<?php
http_response_code(200);
header('Content-Type: application/json', true, 400);
http_response_code(500);

2つの方法でステータスコードを指定していますが、問題ないケースです。常に後から指定した値が反映されました。

curl -i http://localhost/q3.php
HTTP/1.1 500 Internal Server Error

Q4: header('HTTP/...')とheader()の第3引数を使う

<?php
header('HTTP/1.1 200');
header('Content-Type: application/json', true, 400);
header('Error-Code: ABC', true, 500);

2つの方法でステータスコードを指定していますが、問題ないケースです。常に後から指定した値が反映されました。

curl -i http://localhost/q4.php
HTTP/1.1 500 Internal Server Error

Q5: header('HTTP/...'), header()の第3引数, http_response_code()の3つとも使う

<?php
http_response_code(200);
header('Content-Type: application/json', true, 300);
header('HTTP/1.1 400');
http_response_code(500);

header('HTTP/...')http_response_code()を併用しているため後発が優先されない可能性のあるパターンです。 Q2と同様に、header('HTTP/...')で指定した値の優先度が高いため、後からhttp_response_code()を使ってもステータスコードに反映されませんでした。

curl -i http://localhost/q5.php
HTTP/1.1 400 Bad Request

まとめ

PHPでHTTPステータスコードを指定する方法はいくつかありますが、それぞれで小さな差異があることがわかりました。 また、複数の方法を使ってステータスコードを指定すると、常に後から指定した値が反映されるとは限らないこともわかりました。 特にheader('HTTP/...')http_response_code()を併用している場合は、注意が必要です。

Garoonでは、いくつかの理由(内製の独自フレームワークを使っている、20年以上開発している、コードベースが大きい、など)から、例外的であったり異なった方法でHTTPステータスコードを指定している箇所が存在します。 今回はそれらの組み合わせで問題が発生していました。

今回の調査を通してPHPの振る舞いについて理解を深めることができ、とても有意義な時間でした。

更なる謎

Statusヘッダー

実はこの調査を通して、header()経由でStatusヘッダーを指定することでHTTPステータスコードを指定できることに気づきました。 これに関する記載や実装は確認できませんでした。謎です。

<?php
header('Status: 500');
curl -i http://localhost/q6.php
HTTP/1.1 500 Internal Server Error

http_response_code(200)とheader('HTTP/...')

上述した通り、通常であればHTTPステータスコードをheader('HTTP/...')の形式で指定した後にhttp_response_code()を使って指定しても、 先に指定したheader('HTTP/...')での指定値が優先されてhttp_response_code()の指定値は実際のレスポンスには反映されません。 ただし、例外的にhttp_response_code()で200を指定した場合はレスポンスに反映されます。謎です。

<?php
header('HTTP/1.1 400');
http_response_code(200);
curl -i http://localhost/q7.php
HTTP/1.1 200 OK