GoのHTTPレスポンス処理でBody closeとステータス判定を見落とさないレビュー観点

Goで外部APIを呼ぶコードでは、リクエスト生成やタイムアウト設計に目が行きやすい。
しかしレビューで同じくらい重要なのが、返ってきたレスポンスをどう閉じ、どう失敗判定するかである。

特に危ないのは次のような実装だ。

  • err 確認前に resp.Body.Close() を呼ぶ
  • resp.Body.Close() が抜けている
  • HTTPステータスを確認せずJSON decodeしている
  • 非2xxレスポンスの本文を捨て、調査に必要な情報を失う
  • decode失敗とAPI失敗が同じエラーとして扱われる

この記事では外部API設計全体ではなく、
HTTPレスポンス受信後の処理責務に絞ってレビュー観点を整理する。

まず止めたい実装

レスポンス処理が曖昧な実装
func FetchProfile(ctx context.Context, client *http.Client, userID string) (*Profile, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/users/"+userID, nil)
    if err != nil {
        return nil, err
    }

    resp, err := client.Do(req)
    defer resp.Body.Close()
@Reviewer
`err` 確認前に `resp.Body.Close()` を呼んでおり、client.Doが失敗してrespがnilの場合にpanicします。まずerrを確認し、その後でBodyのclose責務を設定してください。
if err != nil { return nil, err } var profile Profile if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { return nil, err }
@Reviewer
HTTPステータスを確認せずに成功レスポンスとしてdecodeしています。404や500のエラー本文をProfileとして読みに行くため、失敗原因が崩れます。
return &profile, nil }

このコードは正常系だけなら動く。
しかし外部APIは失敗する前提で読む必要がある。

レビューで見るべきなのは、client.Do を呼んでいるかではない。
レスポンスを受け取ったあとの成功判定と後始末が正しく並んでいるかである。

なぜ危ないのか

HTTPレスポンス処理のミスは、障害時にだけ露出しやすい。

  • resp がnilのときにpanicする
  • Bodyを閉じず接続が再利用されない
  • 404や500を正常データとしてdecodeしようとする
  • エラーレスポンス本文を読まず、調査情報が消える
  • decodeエラーだけが返り、実際のHTTP失敗が隠れる

外部API連携では、成功時よりも失敗時の情報が重要になる。
レビューでは、失敗レスポンスをどの粒度で呼び出し元へ返すかまで確認したい。

レビューで見たい3つの判断線

1. client.Doerr 確認後にBodyを閉じているか

client.Do がエラーを返した場合、resp はnilのことがある。
そのため、defer resp.Body.Close()err 確認後に置く。

Comment
@Reviewer: `client.Do` のエラー確認前に `resp.Body.Close()` をdeferしています。respがnilの可能性があるため、err確認後にclose責務を設定してください。

2. JSON decode前にステータスを判定しているか

成功レスポンスとエラーレスポンスは、構造が異なることが多い。
ステータスを見ずにdecodeすると、API失敗がdecode失敗に見えてしまう。

Comment
@Reviewer: HTTPステータスを確認せずに成功DTOへdecodeしています。非2xxの場合は先にエラーとして扱い、レスポンス本文を調査可能な形で残してください。

3. 非2xxの本文を読みすぎず、捨てすぎていないか

エラー本文は調査に役立つ。
一方で、無制限に読むと大きなレスポンスでメモリを使いすぎる。

Comment
@Reviewer: 非2xx時のBodyを完全に捨てているため、障害調査に必要なAPI側メッセージが残りません。上限付きで本文を読み、status codeと合わせて返してください。

改善例

レスポンス処理の責務を順番に並べると、レビューしやすくなる。

レスポンス処理を明確にした実装
func FetchProfile(ctx context.Context, client *http.Client, userID string) (*Profile, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/users/"+userID, nil)
    if err != nil {
        return nil, fmt.Errorf("create profile request: %w", err)
    }

    resp, err := client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("fetch profile: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
        return nil, fmt.Errorf("fetch profile: status=%d body=%q", resp.StatusCode, string(body))
    }

    var profile Profile
    if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
        return nil, fmt.Errorf("decode profile response: %w", err)
    }

    return &profile, nil
}

この構造なら、レビューアーは次を確認しやすい。

  • resp がnilのときにBodyへ触らない
  • Bodyのclose責務が固定されている
  • 非2xxを成功DTOとしてdecodeしない
  • エラーレスポンス本文が上限付きで残る
  • 通信失敗、HTTP失敗、decode失敗の文脈が分かれる

エラーレスポンス型を分ける

呼び出し元がステータスコードで分岐するなら、専用エラー型にしてもよい。

ステータスを保持するエラー型
type HTTPStatusError struct {
    StatusCode int
    Body string
}

func (e *HTTPStatusError) Error() string {
    return fmt.Sprintf("unexpected http status: %d", e.StatusCode)
}
Comment
@Reviewer: 呼び出し元が404と500を区別する必要があるなら、文字列エラーではなくステータスコードを保持するエラー型にしてください。ログ用情報と制御用情報を分けるとレビューしやすくなります。

ただし、すべてのAPI呼び出しで独自エラー型を作る必要はない。
レビューでは、呼び出し元がステータス別に判断する要件があるかを確認する。

Body.Close() があるだけでは不十分

defer resp.Body.Close() があればリソース解放としては一歩前進だ。
しかし、次のような実装はまだ危ない。

Closeはあるがステータス判定がない実装
resp, err := client.Do(req)
if err != nil {
    return nil, err
}
defer resp.Body.Close()

var result Result
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
    return nil, err
}
return &result, nil
Comment
@Reviewer: Bodyはcloseされていますが、HTTPステータスの確認がありません。API失敗とJSON decode失敗を混同しないよう、decode前に非2xxを分岐してください。

レビューでは、close、status、decodeをセットで確認する。

レビュー観点チェックリスト

HTTPレスポンス処理レビューの確認項目
  • client.Doerr 確認前に resp.Body へ触っていないか
  • 成功時も失敗時も resp.Body.Close() が呼ばれるか
  • JSON decode前にHTTPステータスを判定しているか
  • 非2xxの本文を上限付きで読み、調査可能な情報を残しているか
  • 通信エラー、HTTPステータスエラー、decodeエラーの文脈が分かれているか
  • 呼び出し元がステータス別に判断するならエラー型で表現しているか

まとめ

GoのHTTPクライアント処理では、リクエストを送るところだけでなく、レスポンスを受けた後の順序が重要になる。
レビューでは、err確認、Body close、ステータス判定、decodeが正しい順番で並んでいるかを確認したい。

外部APIは失敗する。
その前提で、失敗時にもpanicせず、調査可能な情報を残し、呼び出し元が判断できる形に整えることがレビューの役割になる。