こんにちは、極予測やりとりAI というプロダクトの開発責任者をしている しゅん(@MxShun)です。

ある日、cURL は成功し Go HTTP リクエストは失敗する事象に遭遇しました。そのとき調査して分かった原因と仕様を共有します。

目次

事象の詳細

HTTP リクエストヘッダー X-Api-Key 値で認証するサーバと通信をしており、サーバのリアーキテクチャに伴う外部結合テスト中に当事象に遭遇しました。

まず疎通確認のため Go アプリケーションのコンテナ内から cURL でリクエストしてみたところ、200 OK が返ってきました。

curl -v \
> -H 'X-Api-Key: xxx' \
> -d '...' \
> https://...

* Host ...:443 was resolved.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for ...
* [HTTP/2] [1] [:method: POST]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:path: ...]
* [HTTP/2] [1] [user-agent: curl/8.9.1]
* [HTTP/2] [1] [x-api-key: xxx]
> POST ... HTTP/2
> User-Agent: curl/8.9.1
> X-Api-Key: xxx
> 
* upload completely sent off: ... bytes
< HTTP/2 200 
< 
* Connection #0 to host ... left intact
{"status": 200, "message": "OK"}

次に実際の Go アプリケーションから HTTP リクエストしてみたところ、401 Unauthorized が返ってきました。その他リクエストヘッダーやリクエストボディに差異はない状態です。

POST ... HTTP/1.1
User-Agent: Go-http-client/1.1
X-Api-Key: xxx
...

HTTP/1.1 401 Unauthorized
{"status": 401, "message": "Unauthorized"}

※不要情報は省略、秘匿情報はマスクしています。

401

事象の原因

HTTP バージョン差異 が原因でした。
さきの実行結果から分かる通り、cURL は HTTP/2、Go アプリケーションは HTTP/1.1 を利用しています。

原因の切り分けとして cURL でも HTTP/1.1 でリクエストしてみたところ、同様に 401 Unauthorized が返って来ることを確認できました。

curl --http1.1 -v \
> -H 'X-Api-Key: xxx' \
> -d '...' \
> ...

* Host ...:443 was resolved.
* using HTTP/1.x
> POST ... HTTP/1.1
> User-Agent: curl/8.9.1
> X-Api-Key: xxx
> 
* upload completely sent off: ... bytes
< HTTP/1.1 401 Unauthorized
< 
* Connection #0 to host ... left intact
{"status": 401, "message": "Unauthorized"}

HTTP バージョン差異によりなぜこのような事象が発生したかと言うと、下記 2 つの仕様があるためです。

  1. HTTP/2 ではヘッダーは小文字のみ許容される
  2. Go HTTP/1.1 ではヘッダーは MIME 正規化される

それぞれの仕様について言及していきます。

仕様1. HTTP/2 ではヘッダーは小文字のみ許容される

HTTP header fields carry information as a series of key-value pairs.
For a listing of registered HTTP headers, see the “Message Header
Field” registry maintained at <https://www.iana.org/assignments/ message-headers>.

Just as in HTTP/1.x, header field names are strings of ASCII
characters that are compared in a case-insensitive fashion. However,
header field names MUST be converted to lowercase prior to their
encoding in HTTP/2. A request or response containing uppercase
header field names MUST be treated as malformed (Section 8.1.2.6).

RFC 7540 8.1.2. HTTP Header Fields

cURL でリクエストヘッダー X-Api-Key を指定してリクエストすると、cURL が内部的に利用しているライブラリ nghttp により HTTP 通信上は x-api-key として扱われます。
このことは、さきの実行結果からも分かります。

curl -v \
> -H 'X-Api-Key: xxx' \
> -d '...' \
> https://...

// 中略

* [HTTP/2] [1] [x-api-key: xxx] // HTTP 通信上は x-api-key として扱われる

// 中略

> X-Api-Key: xxx // cURL でリクエストヘッダー X-Api-Key を指定

// 後略

仕様2. Go HTTP/1.1 ではヘッダーは MIME 正規化される

Go net/http における Header のアクセサが、net/textprotoMIMEHeader をラップしていることから明らかです。

// Add adds the key, value pair to the header.
// It appends to any existing values associated with key.
// The key is case insensitive; it is canonicalized by
// [CanonicalHeaderKey].
func (h Header) Add(key, value string) {
	textproto.MIMEHeader(h).Add(key, value)
}

Go v1.23.4 net/http Header Add

// Add adds the key, value pair to the header.
// It appends to any existing values associated with key.
func (h MIMEHeader) Add(key, value string) {
	key = CanonicalMIMEHeaderKey(key)
	h[key] = append(h[key], value)
}

Go v1.23.4 net/textproto Header Add

そして、MIME 正規化の仕様は net/textprotoCanonicalMIMEHeaderKey コメントに記載の通りです。

// CanonicalMIMEHeaderKey returns the canonical format of the
// MIME header key s. The canonicalization converts the first
// letter and any letter following a hyphen to upper case;
// the rest are converted to lowercase. For example, the
// canonical key for "accept-encoding" is "Accept-Encoding".
// MIME header keys are assumed to be ASCII only.
// If s contains a space or invalid header field bytes, it is
// returned without modifications.
func CanonicalMIMEHeaderKey(s string) string {

Go v1.23.4 net/textproto CanonicalMIMEHeaderKey

つまり、Go HTTP/1.1 においては X-Api-KeyX-Api-Keyx-api-keyX-Api-Key として扱われます。

まとめ

末筆ながらひとつ訂正です。本記事では、文脈の分かりやすさを優先して HTTP バージョン差異 を原因と記載しました。しかし、実際にはサーバのリアーキテクチャに伴い HTTP リクエストヘッダー X-Api-Key が許容されなくなった、後方互換性がなくなったことが実際の原因になります。

本記事では、cURL は成功し Go HTTP リクエストは失敗するという一見原因の切り分けが難しい事象に着目し、その裏にある下記 2 つの仕様の共有に注力しました。

  1. HTTP/2 ではヘッダーは小文字のみ許容される
  2. Go HTTP/1.1 ではヘッダーは MIME 正規化される

同じような事象に遭遇した際、みなさんの助けになれば幸いです。