Day 028 — HTTPクライアント実装

2026-08-25 🔵 中級者 / Phase 2 実装 HTTPクライアント実装

📚 背景知識(読んでから問題へ)

Day 027ではnet/httpでHTTPサーバー(受け取る側)を実装しました。今日はその逆、http.Clientを使って外部のHTTP APIを呼び出す側(クライアント)を実装します。

Goにはhttp.Getやhttp.Postといったパッケージレベルの便利関数があり、内部的にはhttp.DefaultClient(&http.Client{}のゼロ値相当)が使われます。しかし実務ではこれをそのまま使うのは危険です。

// http.DefaultClient には Timeout が設定されていない(ゼロ値 = 無期限)
resp, err := http.Get("http://example.com")

http.Clientのゼロ値はTimeout: 0、つまり「タイムアウトなし」を意味します。相手サーバーが応答を返さなければ、呼び出し側のgoroutineは無期限にブロックされ続けます。実務のコードでは、必ずTimeoutを明示するか、context.Contextでリクエストごとに寿命を管理します。

client := &http.Client{Timeout: 3 * time.Second}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)

http.NewRequestWithContextでリクエストにcontext.Contextを紐づけると、ctxがキャンセルされたりタイムアウトした瞬間に、進行中のHTTP通信も打ち切られます。Day 016-017で学んだcontextのキャンセル伝播が、ここでも同じ仕組みで働きます。

外部APIの呼び出しには、ネットワークの瞬断や相手サーバーの一時的な過負荷(5xx応答)がつきものです。こうした一時的な障害に対しては、一定回数リトライすることで呼び出し全体の成功率を上げられます。ただし、すべてのエラーをリトライしてよいわけではありません。

  • リトライすべき: ネットワークエラー・5xx(サーバー側の一時的な問題)
  • リトライすべきでない: 4xx(リクエスト自体が不正)・レスポンスのパース失敗(何度呼んでも同じ結果)・ctxがすでにタイムアウト/キャンセル済み

この判別を怠って「エラーなら何でもリトライする」実装にすると、4xxを返す壊れたリクエストを無駄に何度も送りつけたり、すでに諦めたはずの呼び出し元を待たせ続けたりする問題が起きます。

📝 問題

Day 027で作った注文確定API(POST /orders)を拡張し、確定前に外部の在庫確認APIを呼び出すようにしてください。

Part A: StockCheckerインターフェースとHTTP実装

  1. 以下のインターフェースを定義する。
    type StockChecker interface {
        CheckStock(ctx context.Context, sku string) (bool, error)
    }
  2. HTTPStockChecker構造体を実装する。フィールドとしてBaseURL string・Client *http.Client・MaxRetries intを持つこと(http.DefaultClientをそのまま使わないこと)。
  3. CheckStockはGET {BaseURL}/stock/{sku}を呼び出し、レスポンスJSON {"sku": "...", "in_stock": true}をデコードしてin_stockの値を返す。
  4. リクエストは必ずhttp.NewRequestWithContextで作成し、引数のctxを渡すこと。

Part B: リトライとタイムアウトの制御

  1. ネットワークエラー・5xx応答はリトライ対象、4xx応答・レスポンスのデコード失敗はリトライ対象外として区別する。
  2. ctxがすでにキャンセル/タイムアウトしている(または通信中にそうなった)場合は、リトライを試みずに即座にエラーを返す。
  3. Order構造体にSKU stringフィールドを追加する。OrderService.ConfirmOrderはcontext.Contextを受け取るように変更し、金額チェックの直後・メール送信の前にStockChecker.CheckStockを呼ぶ。在庫がない場合はセンチネルエラーErrOutOfStockを返し、在庫確認自体が失敗した場合はセンチネルエラーErrStockUnavailableでラップして返す(OrderServiceは在庫確認がHTTP経由であることを一切知らない設計を保つこと)。
  4. OrderHandler.ConfirmOrderは、リクエストのcontextに2秒のタイムアウトを設定した上でOrderService.ConfirmOrderに渡す。エラー種別ごとに以下のようにステータスコードを振り分ける。
    • ErrInvalidOrder → 400
    • ErrOutOfStock → 409 Conflict
    • ErrStockUnavailable → 502 Bad Gateway(上流の在庫確認APIの障害であり、リクエスト自体は正しいため)
    • それ以外(メール送信失敗等)→ 500

Part C: httptest.NewServerによるテスト

Day 027ではhttptest.NewRecorder(ポートをリッスンしない)を使いましたが、今回はクライアントが実際にHTTP通信を行うためhttptest.NewServer(実際にポートをリッスンするテスト用サーバー)を使ってテーブル駆動テストを書いてください。

  1. 在庫あり → true, nilが返る
  2. 在庫なし → false, nilが返る
  3. 5xxが2回続いた後3回目で成功 → 最終的に成功し、リクエストが3回発生したことを検証する
  4. 5xxがMaxRetries回続く → エラーになり、リクエストがちょうどMaxRetries回発生したことを検証する
  5. 4xx(例: 404)→ リトライされず、リクエストが1回だけ発生したことを検証する
  6. 不正なJSON応答 → リトライされず、リクエストが1回だけ発生したことを検証する
  7. (余力があれば)呼び出し側のctxが先にタイムアウトするケース → サーバーの応答を待たずに速やかにエラーが返ることを検証する

さらに、OrderHandler側のテストはstubStockChecker(StockCheckerインターフェースを満たすテスト用スタブ)を使い、在庫あり/在庫なし/在庫確認APIエラーの3パターンで期待するステータスコードを検証してください。

go run . / go test -v ./...でそのまま動作する完全なコードとして提示すること。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

「リトライすべきエラー」と「リトライすべきでないエラー」を型やフラグで明示的に区別するのが設計の肝です。1回分のHTTPリクエストを行う処理を、全体のリトライループとは別の小さな関数に切り出すと、この区別がコードの構造としてはっきり見えるようになります。

ヒント2 — アプローチ
  • リトライループの骨格はこう組みます。
    for attempt := 0; attempt < maxRetries; attempt++ {
        if attempt > 0 {
            select {
            case <-ctx.Done():
                return false, ctx.Err() // リトライ待ち中にctxが尽きたら即座に諦める
            case <-time.After(backoff * time.Duration(attempt)):
            }
        }
        // 1回分のリクエストを実行し、成否とretryableかどうかを受け取る
    }
  • 1回分のリクエストを担う関数は(inStock bool, retryable bool, err error)のように「リトライすべきかどうか」を戻り値で明示すると、呼び出し側のループがシンプルになります。
  • httptest.NewServerはテスト内で実際にランダムな空きポートをリッスンする本物のHTTPサーバーを起動します。srv.URLがBaseURLになり、srv.Client()(テストサーバー用に設定済みの*http.Client)を使えます。defer srv.Close()を忘れずに。
  • テストを高速化するため、Backoff(リトライ間隔の基準値)を構造体のフィールドにしてテストから短い値に差し替えられるようにしておくと、MaxRetries回のリトライを伴うテストでも数十ミリ秒で完走します。
ヒント3 — コード骨格
type HTTPStockChecker struct {
	BaseURL    string
	Client     *http.Client
	MaxRetries int
	Backoff    time.Duration
}

func (c *HTTPStockChecker) CheckStock(ctx context.Context, sku string) (bool, error) {
	url := fmt.Sprintf("%s/stock/%s", c.BaseURL, sku)
	var lastErr error
	for attempt := 0; attempt < c.MaxRetries; attempt++ {
		if attempt > 0 {
			select {
			case <-ctx.Done():
				return false, fmt.Errorf("check stock: %w", ctx.Err())
			case <-time.After(c.Backoff * time.Duration(attempt)):
			}
		}
		inStock, retryable, err := c.doRequest(ctx, url)
		if err == nil {
			return inStock, nil
		}
		lastErr = err
		if !retryable {
			return false, err
		}
	}
	return false, fmt.Errorf("check stock: retries exhausted: %w", lastErr)
}

func (c *HTTPStockChecker) doRequest(ctx context.Context, url string) (inStock, retryable bool, err error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	// ... c.Client.Do(req) の結果を見て、5xx/ネットワークエラーは retryable=true、
	// 4xx/デコード失敗は retryable=false として返す
}

✅ 模範解答

ファイル: main.go

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"time"
)

// --- Day 027から引き継ぐドメイン層 ---

// Order は注文情報を表す。JSONタグを付け、HTTPボディとの相互変換に使う。
type Order struct {
	ID            string `json:"id"`
	CustomerEmail string `json:"customer_email"`
	TotalYen      int    `json:"total_yen"`
	SKU           string `json:"sku"`
}

// EmailSender は「メールを送る」という意図だけを表現するインターフェース。
type EmailSender interface {
	Send(to, subject, body string) error
}

// SMTPEmailSender は EmailSender の本番実装。
type SMTPEmailSender struct {
	Host string
	Port int
	From string
}

func (s *SMTPEmailSender) Send(to, subject, body string) error {
	addr := fmt.Sprintf("%s:%d", s.Host, s.Port)
	_ = addr
	fmt.Printf("[SMTP %s] to=%s subject=%s\n", addr, to, subject)
	return nil
}

// センチネルエラー群。ハンドラ側は errors.Is でこれらを判定し、
// ステータスコードへのマッピングを行う。
var (
	ErrInvalidOrder     = errors.New("invalid order total")
	ErrOutOfStock       = errors.New("item out of stock")
	ErrStockUnavailable = errors.New("stock check unavailable")
)

// StockChecker は「SKUの在庫があるかどうかを確認する」という意図だけを表現する
// インターフェース。OrderService はこれが外部HTTP APIで実装されていることを知らない。
type StockChecker interface {
	CheckStock(ctx context.Context, sku string) (bool, error)
}

// OrderService は EmailSender と StockChecker、2つのインターフェース型のフィールドだけを持つ。
// net/http を一切importしない。HTTPはOrderServiceの外側(HTTPStockChecker・OrderHandler)だけの関心事。
type OrderService struct {
	sender EmailSender
	stock  StockChecker
}

func NewOrderService(sender EmailSender, stock StockChecker) *OrderService {
	return &OrderService{sender: sender, stock: stock}
}

func (s *OrderService) ConfirmOrder(ctx context.Context, order Order) error {
	if order.TotalYen <= 0 {
		return fmt.Errorf("%w: %d", ErrInvalidOrder, order.TotalYen)
	}

	inStock, err := s.stock.CheckStock(ctx, order.SKU)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrStockUnavailable, err)
	}
	if !inStock {
		return fmt.Errorf("%w: sku=%s", ErrOutOfStock, order.SKU)
	}

	body := fmt.Sprintf("ご注文 #%s(合計%d円)を承りました。", order.ID, order.TotalYen)
	if err := s.sender.Send(order.CustomerEmail, "ご注文確認", body); err != nil {
		return fmt.Errorf("send confirmation email: %w", err)
	}
	return nil
}

// --- HTTPクライアント層(今日の新規追加分) ---

// stockResponse は在庫確認APIのレスポンスJSON形式。
type stockResponse struct {
	SKU     string `json:"sku"`
	InStock bool   `json:"in_stock"`
}

// HTTPStockChecker は StockChecker の本番実装。外部の在庫確認APIをHTTPで呼び出す。
type HTTPStockChecker struct {
	BaseURL    string
	Client     *http.Client
	MaxRetries int
	// Backoff はリトライ間隔の基準値。attempt番目のリトライで Backoff*attempt だけ待つ。
	// テストではこれを短く設定し、リトライロジックを高速に検証する。
	Backoff time.Duration
}

// NewHTTPStockChecker は本番向けデフォルト設定(3秒タイムアウト・3回リトライ)を持つ
// HTTPStockChecker を生成する。http.DefaultClient を直接使わないのは、
// http.DefaultClient にはタイムアウトが一切設定されておらず、
// 相手サーバーが応答を返さない限り呼び出し側が無期限にブロックされうるため。
func NewHTTPStockChecker(baseURL string) *HTTPStockChecker {
	return &HTTPStockChecker{
		BaseURL:    baseURL,
		Client:     &http.Client{Timeout: 3 * time.Second},
		MaxRetries: 3,
		Backoff:    100 * time.Millisecond,
	}
}

// CheckStock は在庫確認APIを呼び出す。一時的な障害(ネットワークエラー・5xx)は
// リトライし、恒久的な失敗(4xx・不正なレスポンス)や ctx のキャンセル/タイムアウトは
// 即座にエラーとして返す。
func (c *HTTPStockChecker) CheckStock(ctx context.Context, sku string) (bool, error) {
	url := fmt.Sprintf("%s/stock/%s", c.BaseURL, sku)

	var lastErr error
	for attempt := 0; attempt < c.MaxRetries; attempt++ {
		if attempt > 0 {
			select {
			case <-ctx.Done():
				return false, fmt.Errorf("check stock: %w", ctx.Err())
			case <-time.After(c.Backoff * time.Duration(attempt)):
			}
		}

		inStock, retryable, err := c.doRequest(ctx, url)
		if err == nil {
			return inStock, nil
		}
		lastErr = err
		if !retryable {
			return false, err
		}
	}
	return false, fmt.Errorf("check stock: retries exhausted: %w", lastErr)
}

// doRequest は1回分のHTTPリクエストを実行する。戻り値の retryable は、
// このエラーがリトライすべき一時的な障害かどうかを表す。
func (c *HTTPStockChecker) doRequest(ctx context.Context, url string) (inStock bool, retryable bool, err error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return false, false, fmt.Errorf("build request: %w", err)
	}

	resp, err := c.Client.Do(req)
	if err != nil {
		// ctx自体がキャンセル/タイムアウトしている場合はリトライしても無駄なので打ち切る。
		if ctx.Err() != nil {
			return false, false, ctx.Err()
		}
		return false, true, fmt.Errorf("do request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 500 {
		return false, true, fmt.Errorf("server error: status %d", resp.StatusCode)
	}
	if resp.StatusCode != http.StatusOK {
		return false, false, fmt.Errorf("unexpected status %d", resp.StatusCode)
	}

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

// --- HTTP層(Day 027から拡張) ---

type errorResponse struct {
	Error string `json:"error"`
}

func writeJSON(w http.ResponseWriter, status int, v interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

type OrderHandler struct {
	svc *OrderService
}

func NewOrderHandler(svc *OrderService) *OrderHandler {
	return &OrderHandler{svc: svc}
}

// ConfirmOrder は POST /orders のハンドラ。
// リクエストのcontextに2秒の上限を設けたうえでOrderServiceを呼び出す。
// これにより、在庫確認APIがどれだけ遅くても、このハンドラ全体が2秒以上ブロックすることはない。
func (h *OrderHandler) ConfirmOrder(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.Header().Set("Allow", http.MethodPost)
		writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
		return
	}

	var order Order
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(&order); err != nil {
		writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid request body: " + err.Error()})
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	if err := h.svc.ConfirmOrder(ctx, order); err != nil {
		switch {
		case errors.Is(err, ErrInvalidOrder):
			writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
		case errors.Is(err, ErrOutOfStock):
			writeJSON(w, http.StatusConflict, errorResponse{Error: err.Error()})
		case errors.Is(err, ErrStockUnavailable):
			// 在庫確認APIという上流サービスの障害。呼び出し元の入力は正しいので502が適切。
			writeJSON(w, http.StatusBadGateway, errorResponse{Error: err.Error()})
		default:
			writeJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()})
		}
		return
	}

	writeJSON(w, http.StatusCreated, order)
}

func newMux(svc *OrderService) http.Handler {
	h := NewOrderHandler(svc)
	mux := http.NewServeMux()
	mux.HandleFunc("/orders", h.ConfirmOrder)
	return mux
}

func main() {
	sender := &SMTPEmailSender{Host: "smtp.example.com", Port: 587, From: "shop@example.com"}
	stock := NewHTTPStockChecker("http://inventory.internal:9090")
	svc := NewOrderService(sender, stock)
	handler := newMux(svc)

	log.Println("listening on :8080")
	if err := http.ListenAndServe(":8080", handler); err != nil {
		log.Fatal(err)
	}
}

ファイル: main_test.go

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"
	"time"
)

// --- HTTPStockChecker のテスト(httptest.NewServer で実際のHTTP往復を検証する) ---

func TestHTTPStockChecker_CheckStock(t *testing.T) {
	tests := []struct {
		name        string
		handler     func(requestCount *int32) http.HandlerFunc
		wantInStock bool
		wantErr     bool
		wantCalls   int32
	}{
		{
			name: "在庫ありは1回のリクエストで成功する",
			handler: func(requestCount *int32) http.HandlerFunc {
				return func(w http.ResponseWriter, r *http.Request) {
					atomic.AddInt32(requestCount, 1)
					json.NewEncoder(w).Encode(stockResponse{SKU: "A1", InStock: true})
				}
			},
			wantInStock: true,
			wantErr:     false,
			wantCalls:   1,
		},
		{
			name: "在庫なしもエラーではなくfalseとして返る",
			handler: func(requestCount *int32) http.HandlerFunc {
				return func(w http.ResponseWriter, r *http.Request) {
					atomic.AddInt32(requestCount, 1)
					json.NewEncoder(w).Encode(stockResponse{SKU: "A1", InStock: false})
				}
			},
			wantInStock: false,
			wantErr:     false,
			wantCalls:   1,
		},
		{
			name: "5xxが2回続いても3回目で成功すればリトライで復帰する",
			handler: func(requestCount *int32) http.HandlerFunc {
				return func(w http.ResponseWriter, r *http.Request) {
					n := atomic.AddInt32(requestCount, 1)
					if n <= 2 {
						w.WriteHeader(http.StatusInternalServerError)
						return
					}
					json.NewEncoder(w).Encode(stockResponse{SKU: "A1", InStock: true})
				}
			},
			wantInStock: true,
			wantErr:     false,
			wantCalls:   3,
		},
		{
			name: "5xxが続く場合はMaxRetries回で打ち切りエラーになる",
			handler: func(requestCount *int32) http.HandlerFunc {
				return func(w http.ResponseWriter, r *http.Request) {
					atomic.AddInt32(requestCount, 1)
					w.WriteHeader(http.StatusInternalServerError)
				}
			},
			wantErr:   true,
			wantCalls: 3,
		},
		{
			name: "4xxはリトライせず即座にエラーになる",
			handler: func(requestCount *int32) http.HandlerFunc {
				return func(w http.ResponseWriter, r *http.Request) {
					atomic.AddInt32(requestCount, 1)
					w.WriteHeader(http.StatusNotFound)
				}
			},
			wantErr:   true,
			wantCalls: 1,
		},
		{
			name: "不正なJSONはリトライせず即座にエラーになる",
			handler: func(requestCount *int32) http.HandlerFunc {
				return func(w http.ResponseWriter, r *http.Request) {
					atomic.AddInt32(requestCount, 1)
					w.Write([]byte(`{not-json`))
				}
			},
			wantErr:   true,
			wantCalls: 1,
		},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			var requestCount int32
			srv := httptest.NewServer(tt.handler(&requestCount))
			defer srv.Close()

			checker := &HTTPStockChecker{
				BaseURL:    srv.URL,
				Client:     srv.Client(),
				MaxRetries: 3,
				Backoff:    5 * time.Millisecond, // テストを高速化するため短く設定
			}

			inStock, err := checker.CheckStock(context.Background(), "A1")

			if tt.wantErr && err == nil {
				t.Fatalf("expected error, got nil")
			}
			if !tt.wantErr && err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if !tt.wantErr && inStock != tt.wantInStock {
				t.Fatalf("inStock = %v, want %v", inStock, tt.wantInStock)
			}
			if got := atomic.LoadInt32(&requestCount); got != tt.wantCalls {
				t.Fatalf("request count = %d, want %d", got, tt.wantCalls)
			}
		})
	}
}

// TestHTTPStockChecker_ContextTimeout は、呼び出し側のcontextがタイムアウトした場合に
// レスポンスを待たずに速やかに打ち切られることを検証する。
func TestHTTPStockChecker_ContextTimeout(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(200 * time.Millisecond) // 呼び出し側のタイムアウトより遅く応答する
		json.NewEncoder(w).Encode(stockResponse{SKU: "A1", InStock: true})
	}))
	defer srv.Close()

	checker := &HTTPStockChecker{
		BaseURL:    srv.URL,
		Client:     srv.Client(),
		MaxRetries: 3,
		Backoff:    5 * time.Millisecond,
	}

	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()

	start := time.Now()
	_, err := checker.CheckStock(ctx, "A1")
	elapsed := time.Since(start)

	if err == nil {
		t.Fatal("expected error due to context timeout, got nil")
	}
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("error = %v, want wrapping context.DeadlineExceeded", err)
	}
	// リトライで待たされず、タイムアウト付近(サーバーの200msより十分短い)で打ち切られること。
	if elapsed > 150*time.Millisecond {
		t.Fatalf("elapsed = %v, want well under server's 200ms sleep", elapsed)
	}
}

// --- OrderHandler の統合テスト(stubStockChecker を使い、HTTPStockCheckerには依存しない) ---

type stubSender struct {
	sentCount int
	err       error
}

func (s *stubSender) Send(to, subject, body string) error {
	if s.err != nil {
		return s.err
	}
	s.sentCount++
	return nil
}

type stubStockChecker struct {
	inStock bool
	err     error
}

func (s *stubStockChecker) CheckStock(ctx context.Context, sku string) (bool, error) {
	if s.err != nil {
		return false, s.err
	}
	return s.inStock, nil
}

func TestOrderHandler_ConfirmOrder(t *testing.T) {
	tests := []struct {
		name       string
		method     string
		body       string
		senderErr  error
		inStock    bool
		stockErr   error
		wantStatus int
	}{
		{
			name:       "在庫があり送信も成功すれば201を返す",
			method:     http.MethodPost,
			body:       `{"id":"A1","customer_email":"a@example.com","total_yen":1000,"sku":"SKU-1"}`,
			inStock:    true,
			wantStatus: http.StatusCreated,
		},
		{
			name:       "合計0円以下は400を返す",
			method:     http.MethodPost,
			body:       `{"id":"A2","customer_email":"b@example.com","total_yen":0,"sku":"SKU-1"}`,
			inStock:    true,
			wantStatus: http.StatusBadRequest,
		},
		{
			name:       "不正なJSONは400を返す",
			method:     http.MethodPost,
			body:       `{invalid`,
			wantStatus: http.StatusBadRequest,
		},
		{
			name:       "在庫切れは409を返す",
			method:     http.MethodPost,
			body:       `{"id":"A3","customer_email":"c@example.com","total_yen":500,"sku":"SKU-2"}`,
			inStock:    false,
			wantStatus: http.StatusConflict,
		},
		{
			name:       "在庫確認APIの障害は502を返す",
			method:     http.MethodPost,
			body:       `{"id":"A4","customer_email":"d@example.com","total_yen":500,"sku":"SKU-3"}`,
			stockErr:   fmt.Errorf("upstream unavailable"),
			wantStatus: http.StatusBadGateway,
		},
		{
			name:       "送信エラー時は500を返す",
			method:     http.MethodPost,
			body:       `{"id":"A5","customer_email":"e@example.com","total_yen":500,"sku":"SKU-1"}`,
			inStock:    true,
			senderErr:  errors.New("smtp connection refused"),
			wantStatus: http.StatusInternalServerError,
		},
		{
			name:       "GETは405を返す",
			method:     http.MethodGet,
			body:       "",
			wantStatus: http.StatusMethodNotAllowed,
		},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			sender := &stubSender{err: tt.senderErr}
			stock := &stubStockChecker{inStock: tt.inStock, err: tt.stockErr}
			svc := NewOrderService(sender, stock)
			handler := NewOrderHandler(svc)

			req := httptest.NewRequest(tt.method, "/orders", bytes.NewBufferString(tt.body))
			rec := httptest.NewRecorder()

			handler.ConfirmOrder(rec, req)

			if rec.Code != tt.wantStatus {
				t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tt.wantStatus, rec.Body.String())
			}
		})
	}
}
▶ 実行結果を見る(go build / go vet / go test -v ./... / 実サーバー起動で検証済み)
$ go vet ./... && go build ./...
(エラーなし)

$ go test -v ./...
=== RUN   TestHTTPStockChecker_CheckStock
=== RUN   TestHTTPStockChecker_CheckStock/在庫ありは1回のリクエストで成功する
=== RUN   TestHTTPStockChecker_CheckStock/在庫なしもエラーではなくfalseとして返る
=== RUN   TestHTTPStockChecker_CheckStock/5xxが2回続いても3回目で成功すればリトライで復帰する
=== RUN   TestHTTPStockChecker_CheckStock/5xxが続く場合はMaxRetries回で打ち切りエラーになる
=== RUN   TestHTTPStockChecker_CheckStock/4xxはリトライせず即座にエラーになる
=== RUN   TestHTTPStockChecker_CheckStock/不正なJSONはリトライせず即座にエラーになる
--- PASS: TestHTTPStockChecker_CheckStock (0.06s)
    --- PASS: TestHTTPStockChecker_CheckStock/在庫ありは1回のリクエストで成功する (0.02s)
    --- PASS: TestHTTPStockChecker_CheckStock/在庫なしもエラーではなくfalseとして返る (0.00s)
    --- PASS: TestHTTPStockChecker_CheckStock/5xxが2回続いても3回目で成功すればリトライで復帰する (0.02s)
    --- PASS: TestHTTPStockChecker_CheckStock/5xxが続く場合はMaxRetries回で打ち切りエラーになる (0.02s)
    --- PASS: TestHTTPStockChecker_CheckStock/4xxはリトライせず即座にエラーになる (0.00s)
    --- PASS: TestHTTPStockChecker_CheckStock/不正なJSONはリトライせず即座にエラーになる (0.00s)
=== RUN   TestHTTPStockChecker_ContextTimeout
--- PASS: TestHTTPStockChecker_ContextTimeout (0.20s)
=== RUN   TestOrderHandler_ConfirmOrder
=== RUN   TestOrderHandler_ConfirmOrder/在庫があり送信も成功すれば201を返す
=== RUN   TestOrderHandler_ConfirmOrder/合計0円以下は400を返す
=== RUN   TestOrderHandler_ConfirmOrder/不正なJSONは400を返す
=== RUN   TestOrderHandler_ConfirmOrder/在庫切れは409を返す
=== RUN   TestOrderHandler_ConfirmOrder/在庫確認APIの障害は502を返す
=== RUN   TestOrderHandler_ConfirmOrder/送信エラー時は500を返す
=== RUN   TestOrderHandler_ConfirmOrder/GETは405を返す
--- PASS: TestOrderHandler_ConfirmOrder (0.01s)
    --- PASS: TestOrderHandler_ConfirmOrder/在庫があり送信も成功すれば201を返す (0.01s)
    --- PASS: TestOrderHandler_ConfirmOrder/合計0円以下は400を返す (0.00s)
    --- PASS: TestOrderHandler_ConfirmOrder/不正なJSONは400を返す (0.00s)
    --- PASS: TestOrderHandler_ConfirmOrder/在庫切れは409を返す (0.00s)
    --- PASS: TestOrderHandler_ConfirmOrder/在庫確認APIの障害は502を返す (0.00s)
    --- PASS: TestOrderHandler_ConfirmOrder/送信エラー時は500を返す (0.00s)
    --- PASS: TestOrderHandler_ConfirmOrder/GETは405を返す (0.00s)
PASS
ok  	example.com/day028	1.573s

(別ターミナルで実サーバーを起動し、存在しないホスト http://inventory.internal:9090 に
 実際にリトライしつつ到達できず 502 になる様子も確認済み)
$ go run .
2026/08/25 05:44:48 listening on :8080

$ curl -s -o /dev/null -w "status=%{http_code}\n" -X POST localhost:8080/orders \
    -d '{"id":"A1","customer_email":"a@example.com","total_yen":1000,"sku":"SKU-1"}'
status=502   # 名前解決できないホストへのアクセスがネットワークエラーとしてリトライされた後、
             # ErrStockUnavailable → 502 Bad Gateway として返された

$ curl -s -o /dev/null -w "status=%{http_code}\n" -X GET localhost:8080/orders
status=405

$ curl -s -o /dev/null -w "status=%{http_code}\n" -X POST localhost:8080/orders -d '{bad'
status=400

go.mod・main.go・main_test.goを1モジュール(module example.com/day028)として配置し、go vet / go build / go test -v ./...で検証済みです。さらに実際にgo run .でサーバーを起動し、実在しないホスト(inventory.internal)への到達不能を意図的なネットワークエラーとして発生させ、リトライを経て502が返る一連の流れも実機で確認しています。

🪜 Step-by-Step 解説

1
http.DefaultClientをそのまま使わない
Client: &http.Client{Timeout: 3 * time.Second},
http.Clientのゼロ値はTimeout: 0(無期限)です。外部サービスを呼ぶコードで無期限のクライアントを使うと、相手が固まった瞬間に自分のgoroutineも道連れで固まります。実務では「必ずTimeoutを明示する」か「必ずcontext.WithTimeout済みのctxを渡す」のどちらか(できれば両方)を徹底します。
2
リクエストにcontext.Contextを紐づける
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
http.NewRequest(contextなし版)ではなくhttp.NewRequestWithContextを使うことで、呼び出し元が持つctxのキャンセル/タイムアウトが、進行中のHTTP通信にもそのまま伝わります。Day 016-017のcontextの階層伝播が、ネットワーク越しの1回の呼び出しにも一貫して適用されます。
3
リトライすべきエラーとそうでないエラーを型ではなく戻り値で区別する
func (c *HTTPStockChecker) doRequest(ctx context.Context, url string) (inStock, retryable bool, err error) {
	// 5xx・ネットワークエラー → retryable = true
	// 4xx・デコード失敗 → retryable = false
}
「エラーが返ってきたら無条件でリトライする」のではなく、1回分のリクエストを担う関数自身に「これはリトライしていい失敗か」を判断させ、戻り値として明示します。呼び出し側のループはこのretryableフラグを見るだけで済み、判断ロジックが1箇所に閉じ込められます。
4
リトライ待機中もctxのキャンセルを見る
select {
case <-ctx.Done():
	return false, fmt.Errorf("check stock: %w", ctx.Err())
case <-time.After(c.Backoff * time.Duration(attempt)):
}
バックオフ待機をtime.Sleepではなくselect+time.After+ctx.Done()で書くことで、「待っている間に呼び出し元がタイムアウトした」場合に、律儀に待ちきってから無駄なリクエストを送るのではなく、その場で打ち切れます。
5
ドメイン層はHTTPが絡んでいることを知らないまま拡張する
type OrderService struct {
	sender EmailSender
	stock  StockChecker
}
StockCheckerはインターフェースなので、OrderServiceは「在庫確認がHTTP経由の外部APIで行われている」という事実を一切知りません。Day 026-027で貫いてきた「ドメイン層はHTTPを知らない」という設計方針が、送信側(サーバー実装)だけでなく受信側(クライアント実装)でも同じように機能することが確認できます。

💡 設計思想・なぜこう書くのか

📌
タイムアウトは「クライアントの責務」として明示的に持つ: Goのhttp.Clientはデフォルトでタイムアウトを持たないという設計は、一見不親切に見えますが、「何秒で諦めるべきか」はエンドポイントやユースケースごとに異なるため、標準ライブラリ側が勝手に決め打ちしないという一貫した思想の表れです。決め打ちの代わりにTimeoutフィールドとcontext.Contextという2つの明示的な手段を提供し、「タイムアウトを考えるのは呼び出し側の責任」という設計をコード上に強制しています。
📌
リトライ判断を「例外の種類」ではなく「戻り値」で表現する: Goにはtry/catchによる例外機構がなく、エラーはすべて戻り値のerrorとして扱われます。今回retryable boolを戻り値に加えたのも同じ発想の延長で、「このエラーは特別だから」という暗黙の分岐(他言語での例外の型によるcatchの使い分け)に頼らず、呼び出し側が読むべき情報をすべて戻り値として並べて渡す、というGoらしい明示性がここでも貫かれています。

🌐 他言語との比較

観点Go(net/http)Node.js(fetch/axios)Python(requests)Java(HttpClient)
デフォルトのタイムアウトなし(http.Clientのゼロ値は無期限。明示しないと危険)fetchはデフォルトで無期限(AbortControllerで明示的に設定が必要)requests.get()はデフォルトで無期限(timeout=引数を必ず渡すのが定石)HttpClient.newBuilder().connectTimeout(...)で明示設定が必要(未設定だと無期限)
キャンセル伝播の仕組みcontext.Contextをhttp.NewRequestWithContextで明示的に紐づけるAbortController/AbortSignalをfetchに渡すスレッドベースのタイムアウトのみで、処理途中のキャンセル伝播は仕組み自体が薄いCompletableFutureのキャンセルやHttpRequest.Builder.timeout()
リトライの実装方法自前のループ、またはresty等の外部ライブラリaxios-retry等の外部ライブラリでインターセプタとして追加urllib3.util.Retry + HTTPAdapter(requestsに組み込み可能な仕組みあり)Failsafe等の外部ライブラリ、またはSpring Retryのアノテーション
標準ライブラリだけで完結するかする(リトライロジックも含め外部依存ゼロで書ける)部分的にする(fetchはある/AbortControllerもある)ほぼする(requests自体はサードパーティだが実質デファクト標準)する(Java 11+のjava.net.http.HttpClient)

Goでは「タイムアウトなしがデフォルト」という一見不便な仕様が、逆に「タイムアウトを意識せずに外部サービスを呼ぶコードは書けない」という安全側への誘導になっています。Pythonのrequestsも同様に無期限がデフォルトであり、"How to use Requests"の公式ドキュメントでもtimeoutを必ず指定するよう明記されているほど、複数言語で共通する落とし穴です。

🏆 実務での使いどころ

  • マイクロサービス間通信: 今回の在庫確認APIのような「自社の別サービスを呼ぶ」パターンは、マイクロサービス構成の実務コードで最も頻繁に書くことになるコードです
  • 決済・外部決済ゲートウェイ連携: 決済APIのように「一時的な5xxはリトライすべきだが、カード情報不正などの4xxは絶対にリトライしてはいけない」という区別が特に重要になる領域で、今回学んだretryableの判断がそのまま活きます
  • サーキットブレーカーへの発展: 今回のリトライロジックは、Phase 4で扱う「サーキットブレーカー」(一定回数連続で失敗したら一定時間リクエスト自体を送らなくなる仕組み)の土台になります
  • errgroupとの組み合わせ: Phase 3で学ぶerrgroupを使えば、複数の外部APIを並行して呼び出しつつ、いずれか1つが失敗したら全体をキャンセルする、という構成にも今回のHTTPStockCheckerはそのまま組み込めます

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
http.Getやhttp.DefaultClientをそのまま本番コードで使ってしまう手軽に書けるサンプルコードの延長で本番コードを書いてしまうhttp.DefaultClientはタイムアウトなしのため、相手サーバーが応答しない限り無期限にブロックされうる。必ずTimeout付きの*http.Clientを自前で用意する
すべてのエラーを同じようにリトライしてしまう(4xxもリトライする)「エラーが起きたらとりあえずもう一度」という直感で実装してしまう4xxは「リクエスト自体が壊れている」ことを示すため、何回リトライしても同じ結果にしかならない。無駄なリトライは相手サーバーへの負荷を増やすだけでなく、呼び出し元を余計に待たせる
resp.Body.Close()を呼び忘れる、または呼ぶ前にエラー判定で早期returnしてしまうerr != nilのときはrespがnilのはずだと思い込み、Closeの位置を後回しにするerr == nilであればrespは必ず非nilであり、resp.Bodyは必ずCloseする必要がある(コネクションプールに接続を返却するため)。err != nilのときは通常respがnilなのでCloseを呼んではいけない、という2つのケースを両方正しく扱う
リトライのバックオフを固定値・待機なしで実装し、相手サーバーへの負荷を考慮しない「とにかく早く成功させたい」という気持ちが優先される相手サーバーが過負荷で5xxを返している状況で待機なしに即リトライすると、負荷をさらに悪化させる。実務では今回のような線形バックオフや指数バックオフ+ジッター(ランダムなゆらぎ)を入れるのが定石

🚀 次のステップ

  • 発展: 今回はerrors.Isで判定できる固定のセンチネルエラーだけでしたが、net.ErrorインターフェースのTimeout() boolメソッドを使って「ネットワークエラーの中でもタイムアウトかどうか」をさらに細かく区別してみましょう。また、指数バックオフ+ジッター(backoff * 2^attempt + rand())への変更も良い練習になります
  • 次回予告: Day 029 — Phase 2 総復習(並行処理を使ったWebスクレイパー実装)(実装)。Phase 2の集大成として、goroutine・channel・今回学んだHTTPクライアント実装を統合し、複数URLを並行して取得するWebスクレイパーを実装します

🎯 自己評価

自分の回答

気づき・メモ