ローカルで普通に動いていた API が、本番環境へ出した途端 400 Bad Request を返します。
バックエンドのログを見ると、そもそもアプリケーションまでリクエストが届いていない。
CDN や WAF のログを見ると、どうやらヘッダが不正だと判定されている。
こういうとき、原因がアプリケーションのロジックではなく、HTTP ヘッダの構文にあることがあります。
この記事では、特に見落としやすい 2 つの例を扱います。
-
If-Matchヘッダの値は"1"のように引用符で囲む必要がある -
Content-Typeの JSON 判定ではapplication/problem+jsonのような+jsonも許容する必要がある
どちらも、普段のフレームワーク上では雑に扱っても動いてしまうことがあります。
しかし、CDN、WAF、リバースプロキシ、ゲートウェイを通すと、仕様に近いところで急に表面化します。
まず、楽観ロックのよくある実装
たとえば、更新 API で楽観ロックを使うとします。サーバー側のレコードに version を持たせておき、クライアントが直前に取得した version と一致するときだけ更新を受け付ける方式です。
HTTP では、この用途に If-Match ヘッダを使えます。
// version はサーバから取得した値
await fetch(url, {
method: 'PUT',
headers: {
'If-Match': String(version),
},
body: JSON.stringify(input),
});
一見、これで動きそうです。
version が 1 なら、送られるヘッダは次のようになります。
If-Match: 1
バックエンド側でこの値を取り出して、DB の version と比較するだけなら、実際に動くこともあります。
const ifMatch = req.headers.get('if-match');
// ifMatch === '1' として扱う
しかし HTTP の仕様上、この書き方は正しくありません。
If-Match: 1 は仕様上は不正
If-Match に入れる値は、HTTP の entity-tag、つまり ETag の形式である必要があります。ETag は単なる生文字列ではなく、引用符で囲まれた文字列です。
つまり、次のように送る必要があります。
If-Match: "1"
1 ではなく "1" です。
JavaScript の文字列として書くなら、こうなります。
await fetch(url, {
method: 'PUT',
headers: {
'If-Match': `"${version}"`,
},
body: JSON.stringify(input),
});
ここで少しややこしいのは、HTTP ヘッダの値としての引用符と、JavaScript の文字列リテラルとしての引用符が別物だという点です。
'If-Match': String(version)
// 送信される値: 1
'If-Match': `"${version}"`
// 送信される値: "1"
HTTP 的に必要なのは、後者です。
なぜローカルでは動いてしまうのか
多くのバックエンドフレームワークでは、ヘッダ値は単なる文字列として取得できます。
そのため、アプリケーション側で次のような実装をしていると、If-Match: 1 でも動いてしまいます。
const ifMatch = req.headers.get('if-match');
if (ifMatch !== String(currentVersion)) {
return new Response('Precondition Failed', { status: 412 });
}
このコードでは、HTTP の entity-tag として正しいかどうかを見ていません。
ただの文字列比較をしているだけです。
だから、ローカルでは通ります。
しかし、本番ではリクエストが次のような経路を通ることがあります。
Browser
↓
CDN
↓
WAF
↓
Reverse Proxy
↓
Application
この途中にある CDN や WAF が、HTTP ヘッダをより厳格に検査していると、If-Match: 1 は不正な値として落とされます。
その場合、アプリケーションにはリクエストが届きません。
バックエンドのログを見ても何も出てこないため、原因が分かりにくくなります。
RFC 上はどう定義されているのか
RFC 9110 では、entity-tag は次のような構文で定義されています。
entity-tag = [ weak ] opaque-tag
weak = %s"W/"
opaque-tag = DQUOTE *etagc DQUOTE
細かい ABNF をすべて覚える必要はありません。
ここで重要なのは、opaque-tag が DQUOTE で囲まれていることです。
つまり、ETag は次のような形式になります。
ETag: "abc"
ETag: "1"
ETag: W/"abc"
W/ が付いているものは weak ETag です。
一方で、次のような値は entity-tag として正しくありません。
If-Match: 1
If-Match: abc
If-Match ヘッダは、* または entity-tag のリストを受け取ります。
If-Match = "*" / 1#entity-tag
そのため、正しい例は次のようになります。
If-Match: "1"
If-Match: "abc"
If-Match: W/"abc"
If-Match: *
楽観ロックで version 番号を使う場合でも、HTTP ヘッダ上では "1" のように entity-tag として送る必要があります。
サーバー側でも正規化しておく
クライアント側で正しく送るのが基本ですが、サーバー側でも If-Match を直接比較するより、最初にパースしておく方が安全です。
たとえば、次のように entity-tag として解釈する関数を用意します。
function parseEntityTag(raw: string | null): string | null {
if (raw == null) return null;
const trimmed = raw.trim();
if (trimmed === '*') {
return '*';
}
const m = /^(?:W\/)?"([^"]*)"$/.exec(trimmed);
return m ? m[1] : null;
}
この関数では、次の値を受け付けます。
If-Match: "1"
If-Match: W/"abc"
If-Match: *
一方で、次のような値は null になります。
If-Match: 1
If-Match: abc
bare な値を後方互換のために受け付けるかどうかは設計判断です。
ただ、少なくとも「仕様上の正しい値」と「たまたま通っていた値」を分けて扱えるようになります。
Content-Type の JSON 判定という類例
ここまでの話は If-Match の引用符でした。
もう 1 つ、同じ種類の注意点があります。
それが、レスポンスの Content-Type を見て JSON かどうか判定する処理です。
たとえば、fetch ラッパで次のようなコードを書くことがあります。
const ct = res.headers.get('content-type') ?? '';
if (!ct.toLowerCase().includes('application/json')) {
throw new Error(`Unexpected content-type: ${ct}`);
}
return await res.json();
このチェック自体に問題はありません。
ゲートウェイが 502 Bad Gateway を返したときに HTML のエラーページが返ってくることがあります。
そういうレスポンスを JSON として res.json() しようとすると、別のエラーになります。
そのため、Content-Type を確認すること自体は妥当です。
ただし、application/json だけを JSON とみなすと、正当な JSON 系のメディアタイプまで弾いてしまいます。
application/problem+json も JSON として扱える
代表例が application/problem+json です。
これは、HTTP API のエラー詳細を表現するためのメディアタイプです。
RFC 9457 で定義されており、ボディは JSON です。
たとえば、次のようなレスポンスです。
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://example.com/problems/invalid-request",
"title": "Invalid request",
"status": 400,
"detail": "Name is required"
}
これは application/json ではありません。
しかし、JSON としてパースしてよいレスポンスです。
なぜなら、末尾に +json が付いているからです。
この +json は、構造化シンタックスサフィックスと呼ばれるものです。
ざっくり言うと、「このメディアタイプの中身は JSON の構造を持っている」という印です。
そのため、次のようなメディアタイプも JSON として扱えます。
application/problem+json
application/vnd.example+json
application/ld+json
つまり、JSON 判定では application/json だけでなく、application/*+json も受け入れる必要があります。
+json を許容する JSON 判定
実装するなら、たとえば次のようにします。
function isJsonContentType(contentType: string | null): boolean {
if (contentType == null) return false;
return /^application\/(?:[\w.+-]+\+)?json\b/i.test(contentType);
}
この関数は、次のような値を JSON として扱います。
application/json
application/json; charset=utf-8
application/problem+json
application/problem+json; charset=utf-8
application/vnd.example+json
application/ld+json
一方で、次のような値は JSON として扱いません。
text/html
text/plain
application/xml
application/jsonml
includes('application/json') だけを見る実装よりも、+json サフィックスを扱える分だけ安全です。
2 つの話に共通していること
If-Match の引用符と、Content-Type の +json は別の話です。ただし実務上の注意点としては似ています。どちらも、アプリケーションの中だけを見ていると雑に扱っても動いてしまいます。If-Match は、バックエンドでただの文字列として比較すれば、1 でも "1" でも動くことがあります。Content-Type は、普段の成功レスポンスだけを見ていれば、application/json だけで足りるように見えます。しかし、実際の HTTP リクエスト / レスポンスは、アプリケーションだけで完結しません。
CDN、WAF、リバースプロキシ、API Gateway、ブラウザー、クライアントライブラリなど、さまざまな層を通ります。
そのどこかで仕様に近い解釈が入ると、ローカルでは見えなかった差が表に出ます。
だから、HTTP ヘッダを扱うところでは、次のようにしておくと事故が減ります。
- 送る側は、仕様に合った形式で送る
- 受ける側は、最初にパースまたは正規化する
- アプリケーション内では、正規化後の値だけを使う
-
Content-Typeのような判定では、完全一致ではなくメディアタイプのルールに沿って見る
まとめ
If-Match で楽観ロック用の version を送る場合、値は "1" のように引用符で囲んだ entity-tag として送る必要があります。
If-Match: "1"
次のような bare な値は、バックエンドでは動くことがあっても、仕様上は正しくありません。
If-Match: 1
また、レスポンスを JSON として扱うかどうかを Content-Type で判定する場合を考えます。ここでは application/json だけでなく、application/problem+json のような +json サフィックス付きメディアタイプも許容する必要があります。
Content-Type: application/problem+json
どちらも小さな話に見えますが、本番環境で CDN / WAF / ゲートウェイを経由すると急に問題化します。
ローカルでは通る。
アプリケーションログには出ない。
でも本番では落ちる。
そういう種類の不具合を避けるために、HTTP ヘッダは「ただの文字列」ではなく、仕様上の構文を持つ値として扱っておくと安心です。