フューチャー技術ブログ

Go1.19 net/http のアップデート

はじめに

TIGの辻です。Go 1.19連載の4本目です。

Go Release Notes に記載がある Minor changes to the library の net/http の3点のアップデートについて触れていきます。1

  • ResponseWriter.WriteHeader がユーザーが定義した 1xx 系の情報レスポンスをサポートした
  • MaxBytesReader が読み取り制限を超過した場合に MaxBytesError を返却するようになった
  • Location ヘッダーのない 3xx 系のレスポンスをエラーとして扱わないようになった

(1)ResponseWriter.WriteHeader がユーザーが定義した 1xx 系の情報レスポンスをサポートした

Go 1.19 からユーザーが定義したHTTPのレスポンスコード 1xx 系の情報ヘッダーをサポートするようになりました。Go 1.18 まではGoの net/http を使ったHTTPサーバでステータスコード 1xx 系を書き込むことはできませんでした。関連するIssue は以下などが挙げられます。

Goの改善内容の詳細を紹介する前に 1xx のステータスコードがどのようなものであるか、どのような挙動になるか簡単におさらいしておきます。

1xx のステータスコード

1xx は情報レスポンスと呼ばれています。

The 1xx (Informational) class of status code indicates an interim response for communicating connection status or request progress prior to completing the requested action and sending a final response. 1xx responses are terminated by the first empty line after the status-line (the empty line signaling the end of the header section). Since HTTP/1.0 did not define any 1xx status codes, a server MUST NOT send a 1xx response to an HTTP/1.0 client.

Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content より

ポイントは以下の3つです。

  1. 要求されたアクションを完了し最終的なレスポンスを送信する前に、コネクションの状態やレスポンスの進捗状況を伝えるための中間的なレスポンスである
  2. 1xx レスポンスは、ステータス行の後の最初の空行(ヘッダーセクションの終わりを示す空行)で終了する
  3. HTTP/1.0 では定義されていないため、サーバーは HTTP/1.0 クライアントに 1xx レスポンスを送信してはいけない

この挙動は具体的にどのようになるのか 1xx 系のステータスコードの1つである 103 Early Hints を使って確認しておきます。

103 Early Hints

103 Early Hints はクライアントが最終的なレスポンスを処理するために役立つヒントを伝えるために使用できるHTTP ステータスコードです。ヘッダだけを送る目的で使われます。リソース配信の最適化に役に立つ、と考えられています。RFCのステータスは EXPERIMENTAL であり、実験的な仕様です。2

103 Early Hints のレスポンスをクライアントが解釈する場合/しない場合のそれぞれの挙動を補足します。

  • 103 Early Hints を解釈する場合の挙動
3.png

イメージ図は日本経済新聞社さんのブログ記事「ChromeとFastlyのEarly Hintsの効果計測に貢献する」より引用しています。

図左のクライアントが図右のサーバーに対して GET /index.html のリソースをリクエストしたときに、そのレスポンスが返却される前にサーバーから 103 Early Hints と link ヘッダーがレスポンスされます。 103 Early Hints のレスポンスを受け取ったクライアントは GET /index.html のレスポンスを待たずして link ヘッダーに記載があるリソース /main.css にリクエストできます。GET /main.css とリクエストすることで、最終的な /index.html で必要なリソース /main.css を予め取得できます。

  • 103 Early Hints を解釈しない場合の挙動
2.png

103 Early Hints を解釈しないクライアントの場合、以下のような挙動になります。クライアントは GET /index.html のレスポンスが返却された後に GET /main.css とリクエストして最終的なレスポンスに必要なリソースを取得します。

net/http の挙動の変化

前置きが少し長くなりました。先程紹介した 103 Early Hints と 200 OKを返却するようなHTTPサーバを net/http を使って実装する場合、以下のようなコードが一例として考えられます。

main.go
package main

import (
"log"
"net/http"
)

func main() {
earlyHintHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Add("Link", "</main.css>; rel=preload; as=style")
w.WriteHeader(http.StatusEarlyHints)

w.Write([]byte("hello"))
})

mux := http.NewServeMux()
mux.Handle("/", earlyHintHandler)
log.Fatal(http.ListenAndServe("localhost:8080", mux))
}

Go 1.18までは WriteHeader() は任意の 1xx 系のステータスコードをサポートしていませんでした。このことは WriteHeader のドキュメントにも記載があります。

Go does not currently support sending user-defined 1xx informational headers, with the exception of 100-continue response header that the Server sends automatically when the Request.Body is read.

そのため、上記の実装を Go 1.18 でビルド&起動したサーバに、クライアントからHTTPリクエストしても機能しません。103 Early Hints のレスポンスはクライアントとのコネクションに書き込まれますが、200 OK のレスポンスはサーバーから書き込まれません。curl3 のクライアントではサーバーからのレスポンスを待ち続けます。

Go 1.18のHTTPサーバの場合
> curl -LIXGET localhost:8080
HTTP/1.1 103 Early Hints
Link: </main.css>; rel=preload; as=style
Date: Tue, 02 Aug 2022 03:39:40 GMT

... (レスポンスを待ち続ける) ...

一方、Go 1.19 でビルド&起動すると、想定どおり 103 Early Hints と 200 OK の両方のレスポンスが得られます。想定どおり機能していることがわかります。なお Link ヘッダーは最終的なレスポンスにも含まれることに注意してください。

Go 1.19のHTTPサーバの場合
> curl -LIXGET localhost:8080
HTTP/1.1 103 Early Hints
Link: </main.css>; rel=preload; as=style

HTTP/1.1 200 OK
Link: </main.css>; rel=preload; as=style
Date: Tue, 02 Aug 2022 03:46:01 GMT
Content-Length: 5
Content-Type: text/plain; charset=utf-8

Goのパッチ内容

パッチは 269997: net/http: allow sending 1xx responses で進められていました。WriteHeader() でステータスコードを書き込むときにステータスコードが 1xx のときはHTTPヘッダーと改行をバッファに書き込んで、それをフラッシュする、ということが主です。

net/http/server.go
func (w *response) WriteHeader(code int) {
if w.conn.hijacked() {
caller := relevantCaller()
w.conn.server.logf("http: response.WriteHeader on hijacked connection from %s (%s:%d)", caller.Function, path.Base(caller.File), caller.Line)
return
}
if w.wroteHeader {
caller := relevantCaller()
w.conn.server.logf("http: superfluous response.WriteHeader call from %s (%s:%d)", caller.Function, path.Base(caller.File), caller.Line)
return
}
checkWriteHeaderCode(code)
+
+ // Handle informational headers
+ if code >= 100 && code <= 199 {
+ // Prevent a potential race with an automatically-sent 100 Continue triggered by Request.Body.Read()
+ if code == 100 && w.canWriteContinue.isSet() {
+ w.writeContinueMu.Lock()
+ w.canWriteContinue.setFalse()
+ w.writeContinueMu.Unlock()
+ }
+
+ writeStatusLine(w.conn.bufw, w.req.ProtoAtLeast(1, 1), code, w.statusBuf[:])
+
+ // Per RFC 8297 we must not clear the current header map
+ w.handlerHeader.WriteSubset(w.conn.bufw, excludedHeadersNoBody)
+ w.conn.bufw.Write(crlf)
+ w.conn.bufw.Flush()
+
+ return
+ }

w.wroteHeader = true
w.status = code

if w.calledHeader && w.cw.header == nil {
w.cw.header = w.handlerHeader.Clone()
}

if cl := w.handlerHeader.get("Content-Length"); cl != "" {
v, err := strconv.ParseInt(cl, 10, 64)
if err == nil && v >= 0 {
w.contentLength = v
} else {
w.conn.server.logf("http: invalid Content-Length of %q", cl)
w.handlerHeader.Del("Content-Length")
}
}
}

(2)MaxBytesReader が読み取り制限を超過した場合に MaxBytesError を返却するようになった

Go 1.18 で追加になったMaxBytesReader でエラーが発生した場合のエラーハンドリングが行いやすくなりました。Go1.18 ではリクエストが大きすぎる場合に errors.New("http: request body too large") としてAPIのクライアントにエラーを返却していました。このエラー固有のエラーハンドリングを行いたい場合、以下のように文字列で比較してエラーハンドリングする必要がありました。

リクエストボディが大きすぎる場合のエラーハンドリング例
b, err := io.ReadAll(r.Body)
if err != nil {
if err.Error() == "http: request body too large" {
// ...
}

Go 1.19ではユーザーがエラーハンドリングしやすいように新たに MaxBytesError 型という error インタフェースを満たした型を返却するようになりました。元のIssueは net/http: add MaxBytesError #30715 です。

  • パッチ内容の一部
net/http/request.go
+// MaxBytesError is returned by MaxBytesReader when its read limit is exceeded.
+type MaxBytesError struct {
+ Limit int64
+}
+
+func (e *MaxBytesError) Error() string {
+ // Due to Hyrum's law, this text cannot be changed.
+ return "http: request body too large"
+}
+
type maxBytesReader struct {
w ResponseWriter
r io.ReadCloser // underlying reader
+ i int64 // max bytes initially, for MaxBytesError
n int64 // max bytes remaining
err error // sticky error
}

私が興味深く思ったことは、わざわざ新しく maxBytesReader 型でバイトの初期サイズを非公開フィールド i として保持するようにしているが、エラーメッセージを変更していない点です。Error() メソッドのコメントによると「Hyrumの法則」に基づくためとのことです。errors.New() で返却する文字列はGo Docとして公開しているわけではないが、Go 1.18で観測可能なエラー発生時の文字列によりエラーハンドリングを行っているユーザーへの配慮を感じました。なお、Hyrumの法則は『Googleのソフトウェアエンジニアリング』の1章にて以下のように紹介されています。

  • Hyrumの法則

あるAPIに十分な数のユーザーがいるとき、APIを作った者自身が契約仕様として何かを約束しているかは重要ではない。作られたシステムが持つあらゆる観察可能(observable)な挙動に関して、それに依存するユーザーが出てくるものである。

(3)Location ヘッダーのない 3xx 系のレスポンスをエラーとして扱わないようになった

Go1.18 までは 3xx 系のレスポンスコードで Location ヘッダーがない場合はエラーとして扱っていました。

一方 RFC7231 Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content ではステータスコード 301 における Location ヘッダーは SHOULD の項目になります。RFC2119 Key words for use in RFCs to Indicate Requirement Levels にあるように MUST であれば、絶対的に含めるべき項目になりますが、SHOULD は推奨される項目です。RFC上は Location ヘッダーが含まれないことも許容されます。

このことから Go の実装としても Location ヘッダーがなくてもエラーとせずにレスポンスをAPIの呼び出し元に返却するように改善しました。

リアルワールドでは net/http: can’t read 301 response without a Location header #49281 という課題がありました。AWSが提供するS3のURLにHTTPリクエストしたときにレスポンスコード 301 で返却されたが Location ヘッダーが含まれないためにGoのエラーが発生する。レスポンスヘッダー x-amz-bucket-region から想定するリージョンを取得できず、困っていた、とのことです。この挙動は Go1.19 で改善されます。

main.go
package main

import (
"errors"
"fmt"
"net/http"
)

func getBucketRegion(bucket string) (string, error) {
// Construct client that makes one request and does not follow redirects
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get("https://" + bucket + ".s3.us-west-1.amazonaws.com/")
if err != nil {
return "", err // <-- function will return here
}

if resp.StatusCode == 200 {
return "us-west-1", nil
} else if resp.StatusCode == 404 {
return "", errors.New("Bucket does not exist.")
}

return resp.Header.Get("x-amz-bucket-region"), nil
}

func main() {
region, err := getBucketRegion("test")
fmt.Printf("region: %s\n", region)
fmt.Printf("err: %v\n", err)
}

まとめ

net/http のアップデートはリリースノートではさらっと3行記載があるだけですが、それぞれの背景やパッチ内容を含めて紹介しました。リアルワールド感あふれる課題やニーズを感じることでき、とてもわくわくしました。

本記事では net/http のアップデートを紹介しました。その他にも net/urlJoinPathURL.JoinPath が追加になっています。HTTPはもちろんですが、その他のプロトコルにおいても、便利にURLを組み立てられるようになっています。

最後まで読んでいただき、ありがとうございました!


  1. 1.なお2022年8月2日にGo1.19 rc2で調べています。
  2. 2.Chrome と Fastly による実装実験が始まっています。https://www.fastly.com/jp/blog/beyond-server-push-experimenting-with-the-103-early-hints-status-code
  3. 3.curlのバージョンは7.83.1を使っています。