Day 059 — 2026-10-02 | ミドルウェア設計パターン: ルートごとに上限が違うレート制限——トークンバケットを60行で書き、守りの「どこ」に置くかを10個のテストで固める

2026-10-02 🟡 上級者 / Phase 3 実装 ミドルウェア設計パターン

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

Day 057 で、ルートごとに違う守りを「宣言表の1列」で選ぶ形にしました。今日はその表に 「上限」の列を1つ足し、レート制限ミドルウェアを書きます。論点は次の3つです。

  1. 何を数えるか: トークンバケット。バケツ(容量 Burst)に毎秒 PerSecond 個のトークンが溜まり、1リクエストで1個使う。空なら断る。「平均は毎秒 N 回まで、瞬間的には Burst 回まで」を1つの仕組みで表せる
  2. 誰の予算か: ルートごと・主体ごとに別のバケツ。キーは「認証済みなら主体、そうでなければ接続元 IP」
  3. 守りのどこに置くか: 認証より外か内か。ここが今日の設計判断で、10個のテストのうち3個がこの位置を縛る

断り方は仕様で決まっている

  • 状態コードは 429 Too Many Requests(RFC 6585 §4)。Retry-After を付けてよい
  • Retry-After の値は秒数の整数か日時(RFC 9110 §10.2.3)。小数は書けない。切り上げないと「言われた秒数を待ったのにまた 429」になる(例: 2.5 秒待ちを 2 と返す)
  • 参照日 2026-10-02。RFC 本文の再取得はしていない(節番号は手元知識。実装前に https://www.rfc-editor.org/rfc/rfc6585 と https://www.rfc-editor.org/rfc/rfc9110 で確認すること)

トークンバケットはタイマーを持たない

「毎秒トークンを足す goroutine」は要りません。来たときに、前回からの経過時間 × レート だけ足す(上限 Burst で頭打ち)。bucket{tokens, last} の2つの値だけで済み、主体が100万いても goroutine は増えません。代わりに、来なくなった主体のバケツが残り続けるので、掃除が要ります(R10)。

世界の実物との位置づけ

golang.org/x/time/rate(v0.16.0。Go チームが保守する標準的な実装)も同じトークンバケットです。ただしキーごとの表は持たないので、HTTP で使うには「キー → *rate.Limiter」の表と掃除を自分で書きます(今日の ratelimit.go が書くのはまさにその部分と、バケツ自体の計算)。今日は計算も自分で書き、最後に x/time/rate と20万回ずつ突き合わせて一致を確かめます(模範解答の実測 4)。

📝 問題

要件(R1〜R10。すべてテストになっている)

#要件テスト
R1Burst 回まで即時に通し、超えたら 429TestBurstThen429WithRetryAfter
R2429 に Retry-After(整数秒、切り上げ)。その秒数だけ待てば通るTestRefillAfterWaiting TestRetryAfterIsCeiling
R3主体ごとに別の予算(alice の使い切りが bob に影響しない)TestPerPrincipal
R4ルートごとに別の予算(POST /orders の使い切りが GET /orders/{id} に影響しない)TestPerRoute
R5匿名ルートは接続元 IP ごと。ポート違いは同じ IP、X-Forwarded-For は信用しないTestAnonymousByIPAndIgnoresXFF
R6認証・認可で弾かれた 401・403 は予算を使わないTestRejectedDoesNotConsumeBudget
R7429 にしたリクエストは本体のハンドラに届かないTestLimitedRequestNeverReachesHandler
R8Limit のゼロ値(決め忘れ)は登録時に panic。無制限は Unlimited と明示TestUndecidedLimitPanics TestUnlimitedIsExplicit
R9同時アクセスでも許可数がちょうど Burst(-race で通る)TestConcurrentExactlyBurst
R10来なくなった主体のバケツは消える。時計が戻っても補充しないTestIdleBucketsAreSwept TestClockGoingBackwardsDoesNotMintTokens

課題

  1. ratelimit.go を実装する(Limit の検査、Limiter、Middleware)
  2. server.go の TODO で、l.Middleware(...) を guards のどこに入れるか決める。R6 と R3 がその位置を決める
  3. 次の問いに、実装前に予想を書く - 問A: Retry-After を int(wait.Seconds())(切り捨て)にすると、どのテストが落ちるか - 問B: レート制限を認証の外側(guards の先頭)に置くと、どのテストが落ちるか。なぜか - 問C: X-Forwarded-For を読む実装は、なぜ「予算を無限に作れる」のか。読んでよいのはどんなときか - 問D: 主体が100万人いて、各自が1回ずつ来て二度と来ない場合、掃除が無いと何が起きるか

準備

mkdir day059 && cd day059
# 下の go.mod・main.go・server.go・ratelimit.go・main_test.go を置く
go test -count=1 ./...        # 最初は ratelimit.go の panic で落ちる
go test -race -count=1 ./...  # 実装後は -race でも通ること(R9)

go.mod:

module day059

go 1.24
// 注文 API の部品(Day 057 の宣言表を縮めたもの)。組み立ては server.go、レート制限は ratelimit.go。
package main

import (
	"context"
	"crypto/subtle"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"time"
)

type Middleware func(http.Handler) http.Handler

// Chain は mws を「先に書いたものが外側」になるように h に被せる(Day 056 の修正版)。
func Chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

// ---- 認証(Day 057 の RequireUser/RequireAdmin を、主体を context に入れる形にしたもの) ----

type Principal struct{ Name, Role string } // Role は "user" か "admin"

type principalKey struct{}

func PrincipalFrom(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(principalKey{}).(Principal)
	return p, ok
}

type Config struct {
	Tokens map[string]Principal // Bearer トークン → 主体
}

func bearer(r *http.Request) string {
	const p = "Bearer "
	h := r.Header.Get("Authorization")
	if len(h) > len(p) && h[:len(p)] == p {
		return h[len(p):]
	}
	return ""
}

// Authenticate は主体を特定して context に入れる。分からなければ 401。
func Authenticate(cfg Config) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			t := bearer(r)
			for tok, p := range cfg.Tokens { // 件数が少ない前提。全件を定数時間で比べる
				if subtle.ConstantTimeCompare([]byte(t), []byte(tok)) == 1 {
					next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, p)))
					return
				}
			}
			w.Header().Set("WWW-Authenticate", `Bearer realm="orders"`) // RFC 6750 §3
			http.Error(w, "unauthorized", http.StatusUnauthorized)
		})
	}
}

// RequireAdmin は Authenticate の内側に置く。誰かは分かったが権限が無ければ 403。
func RequireAdmin(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if p, _ := PrincipalFrom(r.Context()); p.Role != "admin" {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// ---- 本体のハンドラ(中身は今日の論点ではないので最小) ----

func catalog(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `[{"sku":"A-1"}]`) }
func getOrder(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, `{"id":%q}`, r.PathValue("id"))
}
func createOrder(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) }
func listUsers(w http.ResponseWriter, r *http.Request)   { fmt.Fprint(w, `[{"name":"alice"}]`) }

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	cfg := Config{Tokens: map[string]Principal{
		os.Getenv("ALICE_TOKEN"): {"alice", "user"},
		os.Getenv("ADMIN_TOKEN"): {"root", "admin"},
	}}
	if os.Getenv("ALICE_TOKEN") == "" || os.Getenv("ADMIN_TOKEN") == "" {
		logger.Error("ALICE_TOKEN / ADMIN_TOKEN を設定してください")
		os.Exit(1)
	}
	logger.Info("listening", "addr", ":8080")
	if err := http.ListenAndServe(":8080", newServer(cfg, NewLimiter(time.Now))); err != nil {
		logger.Error("server", "err", err)
	}
}
// server.go(守りの組み立てのうち、レート制限をどこに置くかを決める)
package main

import (
	"fmt"
	"net/http"
)

type Access int

const (
	accessUnset Access = iota // ゼロ値は「決めていない」。登録時に panic
	Public
	User
	Admin
)

type Route struct {
	Pattern string
	Access  Access
	Limit   Limit // Day 057 の宣言表に足した列。ゼロ値は「決めていない」
	Handler http.HandlerFunc
}

func routeTable() []Route {
	return []Route{
		{"GET /api/v1/catalog", Public, Limit{PerSecond: 5, Burst: 10}, catalog},
		{"GET /api/v1/orders/{id}", User, Limit{PerSecond: 20, Burst: 40}, getOrder},
		{"POST /api/v1/orders", User, Limit{PerSecond: 1, Burst: 5}, createOrder},
		{"GET /admin/users", Admin, Limit{PerSecond: 2, Burst: 4}, listUsers},
	}
}

func newServer(cfg Config, l *Limiter) http.Handler { return newServerFrom(cfg, routeTable(), l) }

func newServerFrom(cfg Config, routes []Route, l *Limiter) http.Handler {
	mux := http.NewServeMux()
	for _, rt := range routes {
		var guards []Middleware
		switch rt.Access {
		case Public:
		case User:
			guards = append(guards, Authenticate(cfg))
		case Admin:
			guards = append(guards, Authenticate(cfg), RequireAdmin)
		default:
			panic(fmt.Sprintf("route %q: Access が決まっていない", rt.Pattern))
		}
		// TODO: l.Middleware(rt.Pattern, rt.Limit) を guards のどこに入れるか決める
		mux.Handle(rt.Pattern, Chain(rt.Handler, guards...))
	}
	return mux
}
// ratelimit.go(ここを実装する)
package main

import (
	"math"
	"net/http"
	"time"
)

// Limit は1ルート・1主体あたりの上限。PerSecond は定常の許可レート、Burst はバケツの大きさ。
// ゼロ値は「決めていない」として登録時に panic させること(R8)。
type Limit struct {
	PerSecond float64
	Burst     int
}

// Unlimited は「無制限にする」と明示する値。
var Unlimited = Limit{PerSecond: math.Inf(1), Burst: math.MaxInt32}

type Limiter struct {
	// TODO: 状態を持つ。時計は now から取る(テストが差し替える)
}

func NewLimiter(now func() time.Time) *Limiter { panic("TODO") }

// size は保持しているバケツの数(R10 のテストが使う)。
func (l *Limiter) size() int { panic("TODO") }

// Middleware は pattern・lim 用のミドルウェアを返す。上限超過なら 429 と Retry-After を返す。
func (l *Limiter) Middleware(pattern string, lim Limit) Middleware { panic("TODO") }

var _ http.Handler // import を残すための行。実装したら消してよい
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

type fakeClock struct {
	mu sync.Mutex
	t  time.Time
}

func (c *fakeClock) Now() time.Time          { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
func (c *fakeClock) Advance(d time.Duration) { c.mu.Lock(); c.t = c.t.Add(d); c.mu.Unlock() }

var testCfg = Config{Tokens: map[string]Principal{
	"tok-alice": {"alice", "user"},
	"tok-bob":   {"bob", "user"},
	"tok-root":  {"root", "admin"},
}}

func setup(routes []Route) (http.Handler, *fakeClock, *Limiter) {
	clk := &fakeClock{t: time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC)}
	l := NewLimiter(clk.Now)
	if routes == nil {
		routes = routeTable()
	}
	return newServerFrom(testCfg, routes, l), clk, l
}

func do(h http.Handler, method, path, token, remote string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(method, path, nil)
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	if remote != "" {
		req.RemoteAddr = remote
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func TestBurstThen429WithRetryAfter(t *testing.T) { // R1, R2
	h, _, _ := setup(nil)
	for i := 0; i < 5; i++ { // POST /orders は Burst 5
		if c := do(h, "POST", "/api/v1/orders", "tok-alice", "").Code; c != 201 {
			t.Fatalf("%d 回目: %d", i+1, c)
		}
	}
	rec := do(h, "POST", "/api/v1/orders", "tok-alice", "")
	if rec.Code != 429 || rec.Header().Get("Retry-After") != "1" {
		t.Fatalf("code=%d Retry-After=%q", rec.Code, rec.Header().Get("Retry-After"))
	}
}

func TestRefillAfterWaiting(t *testing.T) { // R2: 言われた秒数だけ待てば通る
	h, clk, _ := setup(nil)
	for i := 0; i < 5; i++ {
		do(h, "POST", "/api/v1/orders", "tok-alice", "")
	}
	rec := do(h, "POST", "/api/v1/orders", "tok-alice", "")
	clk.Advance(1 * time.Second) // Retry-After: 1
	if c := do(h, "POST", "/api/v1/orders", "tok-alice", "").Code; c != 201 {
		t.Fatalf("Retry-After=%s 待ったのに %d", rec.Header().Get("Retry-After"), c)
	}
	if c := do(h, "POST", "/api/v1/orders", "tok-alice", "").Code; c != 429 {
		t.Fatalf("補充は1つ分だけのはずが %d", c)
	}
}

func TestRetryAfterIsCeiling(t *testing.T) { // R2: 0.4/s なら 2.5 秒 → 3
	routes := []Route{{"POST /x", User, Limit{PerSecond: 0.4, Burst: 1}, createOrder}}
	h, clk, _ := setup(routes)
	do(h, "POST", "/x", "tok-alice", "")
	rec := do(h, "POST", "/x", "tok-alice", "")
	if got := rec.Header().Get("Retry-After"); got != "3" {
		t.Fatalf("Retry-After=%q, want 3", got)
	}
	clk.Advance(2500 * time.Millisecond)
	if c := do(h, "POST", "/x", "tok-alice", "").Code; c != 201 {
		t.Fatalf("2.5 秒後に %d", c)
	}
}

func TestPerPrincipal(t *testing.T) { // R3
	h, _, _ := setup(nil)
	for i := 0; i < 6; i++ {
		do(h, "POST", "/api/v1/orders", "tok-alice", "")
	}
	if c := do(h, "POST", "/api/v1/orders", "tok-bob", "").Code; c != 201 {
		t.Fatalf("bob が alice の巻き添え: %d", c)
	}
}

func TestPerRoute(t *testing.T) { // R4
	h, _, _ := setup(nil)
	for i := 0; i < 6; i++ {
		do(h, "POST", "/api/v1/orders", "tok-alice", "")
	}
	if c := do(h, "GET", "/api/v1/orders/7", "tok-alice", "").Code; c != 200 {
		t.Fatalf("別ルートが巻き添え: %d", c)
	}
}

func TestAnonymousByIPAndIgnoresXFF(t *testing.T) { // R5
	h, _, _ := setup(nil)
	for i := 0; i < 10; i++ { // catalog は Burst 10
		req := httptest.NewRequest("GET", "/api/v1/catalog", nil)
		req.RemoteAddr = "203.0.113.9:5000" // 接続ごとにポートは変わるが IP は同じ
		req.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", i))
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		if rec.Code != 200 {
			t.Fatalf("%d 回目: %d", i+1, rec.Code)
		}
	}
	if c := do(h, "GET", "/api/v1/catalog", "", "203.0.113.9:5001").Code; c != 429 {
		t.Fatalf("同じ IP・別ポートが通った: %d", c)
	}
	if c := do(h, "GET", "/api/v1/catalog", "", "203.0.113.10:5000").Code; c != 200 {
		t.Fatalf("別 IP が巻き添え: %d", c)
	}
}

func TestRejectedDoesNotConsumeBudget(t *testing.T) { // R6: 401・403 は予算を使わない
	h, _, _ := setup(nil)
	for i := 0; i < 100; i++ {
		do(h, "POST", "/api/v1/orders", "wrong", "")
		do(h, "GET", "/admin/users", "tok-alice", "") // 403
	}
	for i := 0; i < 5; i++ {
		if c := do(h, "POST", "/api/v1/orders", "tok-alice", "").Code; c != 201 {
			t.Fatalf("alice の %d 回目: %d", i+1, c)
		}
	}
	for i := 0; i < 4; i++ {
		if c := do(h, "GET", "/admin/users", "tok-root", "").Code; c != 200 {
			t.Fatalf("root の %d 回目: %d", i+1, c)
		}
	}
}

func TestLimitedRequestNeverReachesHandler(t *testing.T) { // R7
	var n atomic.Int32
	routes := []Route{{"POST /x", User, Limit{PerSecond: 1, Burst: 2}, func(w http.ResponseWriter, r *http.Request) { n.Add(1) }}}
	h, _, _ := setup(routes)
	for i := 0; i < 10; i++ {
		do(h, "POST", "/x", "tok-alice", "")
	}
	if n.Load() != 2 {
		t.Fatalf("ハンドラが %d 回呼ばれた(2 のはず)", n.Load())
	}
}

func TestUndecidedLimitPanics(t *testing.T) { // R8
	defer func() {
		if recover() == nil {
			t.Fatal("Limit のゼロ値が登録できてしまった")
		}
	}()
	setup([]Route{{"GET /x", Public, Limit{}, catalog}})
}

func TestUnlimitedIsExplicit(t *testing.T) { // R8
	h, _, _ := setup([]Route{{"GET /x", Public, Unlimited, catalog}})
	for i := 0; i < 1000; i++ {
		if c := do(h, "GET", "/x", "", "").Code; c != 200 {
			t.Fatalf("%d 回目: %d", i+1, c)
		}
	}
}

func TestConcurrentExactlyBurst(t *testing.T) { // R9: -race で通り、許可数がちょうど Burst
	routes := []Route{{"GET /x", Public, Limit{PerSecond: 0.001, Burst: 50}, catalog}}
	h, _, _ := setup(routes)
	var ok atomic.Int32
	var wg sync.WaitGroup
	for i := 0; i < 400; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if do(h, "GET", "/x", "", "198.51.100.1:1").Code == 200 {
				ok.Add(1)
			}
		}()
	}
	wg.Wait()
	if ok.Load() != 50 {
		t.Fatalf("許可 %d 件(50 のはず)", ok.Load())
	}
}

func TestIdleBucketsAreSwept(t *testing.T) { // R10: 来なくなった主体のバケツは消える
	h, clk, l := setup(nil)
	for i := 0; i < 1000; i++ {
		do(h, "GET", "/api/v1/catalog", "", fmt.Sprintf("10.0.%d.%d:1", i/256, i%256))
	}
	if l.size() != 1000 {
		t.Fatalf("size=%d", l.size())
	}
	clk.Advance(10 * time.Second) // catalog は 5/s なので 2 秒で満タンに戻る
	clk.Advance(time.Minute)      // sweepEvery を越える
	do(h, "GET", "/api/v1/catalog", "", "192.0.2.1:1")
	if l.size() != 1 {
		t.Fatalf("掃除後 size=%d(1 のはず)", l.size())
	}
}

func TestClockGoingBackwardsDoesNotMintTokens(t *testing.T) {
	routes := []Route{{"GET /x", Public, Limit{PerSecond: 1, Burst: 1}, catalog}}
	h, clk, _ := setup(routes)
	do(h, "GET", "/x", "", "")
	clk.Advance(-time.Hour)
	if c := do(h, "GET", "/x", "", "").Code; c != 429 {
		t.Fatalf("時計が戻ったら通った: %d", c)
	}
}

💡 ヒント

ヒント1(方向性)

バケツは「トークン数」と「最後に計算した時刻」の2値です。リクエストが来たら、(1) 経過時間 × レート だけ足して Burst で頭打ち、(2) 1以上なら1引いて通す、(3) 足りなければ「あと何秒で1に届くか」を返す。タイマーは使いません。

ヒント2(アプローチ)
  • 待ち時間は (1 - tokens) / PerSecond 秒。math.Ceil で整数秒にして strconv.Itoa
  • キーは pattern + "\x00" + who(ルート × 主体)。who は PrincipalFrom(ctx)、無ければ net.SplitHostPort(r.RemoteAddr) の host
  • map は sync.Mutex で守る。時計は l.now() を1回だけ呼んで使い回す
  • 掃除は「前回の掃除から1分以上経っていたら、全バケツを見て、補充したら満タンになるものを消す」。消しても次に来たとき満タンで作り直すので、観測できる結果は変わらない
  • 位置は「R6: 弾かれた分を数えない」から決まる。主体が分かってから、かつ通すと決まってから数える
ヒント3(コード骨格)
type bucket struct {
	tokens float64
	last   time.Time
	lim    Limit
}

func (l *Limiter) allow(key string, lim Limit) (bool, time.Duration) {
	now := l.now()
	l.mu.Lock()
	defer l.mu.Unlock()
	// 1) 必要なら掃除  2) バケツを取る(無ければ満タンで作る)
	// 3) tokens = min(Burst, tokens + 経過秒 × PerSecond)。経過が負なら足さない
	// 4) tokens >= 1 なら引いて (true, 0)、足りなければ (false, (1-tokens)/PerSecond 秒)
}

✅ 模範解答

模範解答を表示する(まず問A〜Dの予想を書いてから)

1. 採った案

mux ─┬─ GET /api/v1/catalog     → [limit: ip]                          → catalog
     ├─ GET /api/v1/orders/{id} → Authenticate → [limit: user×route]   → getOrder
     ├─ POST /api/v1/orders     → Authenticate → [limit: user×route]   → createOrder
     └─ GET /admin/users        → Authenticate → RequireAdmin → [limit] → listUsers
  • レート制限は守りの一番内側(ハンドラの直前)。401・403 は予算を使わず(R6)、主体も分かっている(R3)
  • 状態は Limiter 1つ(map + Mutex)。ルートごとの差は Limit の値と、キーに入れる pattern だけ
  • 新しいルートを足すときは表に1行。Limit を書き忘れると起動時に panic(Day 057 の Access と同じ作法)

2. ratelimit.go(模範解答)

// ratelimit.go(模範解答): トークンバケット。補充は「来たときにまとめて」計算し、タイマーも goroutine も持たない。
package main

import (
	"fmt"
	"math"
	"net"
	"net/http"
	"strconv"
	"sync"
	"time"
)

// Limit は1ルート・1主体あたりの上限。PerSecond は定常の許可レート、Burst はバケツの大きさ(同時に使える最大数)。
type Limit struct {
	PerSecond float64
	Burst     int
}

var Unlimited = Limit{PerSecond: math.Inf(1), Burst: math.MaxInt32}

func (l Limit) valid() bool { return l.PerSecond > 0 && l.Burst >= 1 }

type bucket struct {
	tokens float64
	last   time.Time
	lim    Limit
}

// refilled は now 時点のトークン数(Burst で頭打ち)。時計が戻っても減らさない。
func (b *bucket) refilled(now time.Time) float64 {
	if d := now.Sub(b.last).Seconds(); d > 0 {
		return math.Min(float64(b.lim.Burst), b.tokens+d*b.lim.PerSecond)
	}
	return b.tokens
}

type Limiter struct {
	now       func() time.Time
	mu        sync.Mutex
	m         map[string]*bucket
	lastSweep time.Time
}

const sweepEvery = time.Minute

func NewLimiter(now func() time.Time) *Limiter {
	return &Limiter{now: now, m: map[string]*bucket{}, lastSweep: now()}
}

// allow は key のバケツから1つ取る。取れなければ、次に取れるまでの待ち時間を返す。
func (l *Limiter) allow(key string, lim Limit) (bool, time.Duration) {
	now := l.now()
	l.mu.Lock()
	defer l.mu.Unlock()
	if now.Sub(l.lastSweep) >= sweepEvery {
		l.sweep(now)
	}
	b, ok := l.m[key]
	if !ok {
		b = &bucket{tokens: float64(lim.Burst), last: now, lim: lim}
		l.m[key] = b
	}
	b.tokens, b.last = b.refilled(now), now
	if b.tokens >= 1 {
		b.tokens--
		return true, 0
	}
	return false, time.Duration((1 - b.tokens) / lim.PerSecond * float64(time.Second))
}

// sweep は「放っておいても満タンに戻る」バケツを消す。消しても次に来たとき満タンで作り直すので結果は同じ。
func (l *Limiter) sweep(now time.Time) {
	for k, b := range l.m {
		if b.refilled(now) >= float64(b.lim.Burst) {
			delete(l.m, k)
		}
	}
	l.lastSweep = now
}

func (l *Limiter) size() int {
	l.mu.Lock()
	defer l.mu.Unlock()
	return len(l.m)
}

// who は「誰の予算か」。認証済みなら主体、そうでなければ接続元 IP。
// X-Forwarded-For は信用しない(クライアントが自由に書けるので、読むと予算を無限に作れる)。
func who(r *http.Request) string {
	if p, ok := PrincipalFrom(r.Context()); ok {
		return "u:" + p.Name
	}
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		host = r.RemoteAddr
	}
	return "ip:" + host
}

// Middleware は pattern ごとに別のバケツを持つ。上限を超えたら 429 と Retry-After を返し、内側へ通さない。
func (l *Limiter) Middleware(pattern string, lim Limit) Middleware {
	if !lim.valid() {
		panic(fmt.Sprintf("route %q: Limit が決まっていない(無制限なら Unlimited)", pattern))
	}
	if lim == Unlimited {
		return func(next http.Handler) http.Handler { return next }
	}
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ok, wait := l.allow(pattern+"\x00"+who(r), lim)
			if !ok {
				// RFC 9110 §10.2.3: Retry-After は整数秒。切り上げないと「待ったのにまた 429」になる
				w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
				http.Error(w, "too many requests", http.StatusTooManyRequests) // RFC 6585 §4
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

3. server.go の差分(これだけ)

		// 守りの「内側」に置く: 401・403 は予算を使わない(主体が分かってから数える)
		guards = append(guards, l.Middleware(rt.Pattern, rt.Limit))

4. 実測(Go 1.27.1 / darwin arm64 / Apple M1、2026-10-02)

テスト: go test -race -count=1 -v ./... で 13 件すべて PASS。実サーバーでも確認した(go build した本物の net/http サーバーに curl で POST /api/v1/orders を7回)。

HTTP/1.1 201 Created        ← 1〜5回目(Burst 5)
HTTP/1.1 429 Too Many Requests  Retry-After: 1   ← 6回目
HTTP/1.1 429 Too Many Requests  Retry-After: 1   ← 7回目
1.1 秒待って 8回目 → 201

テストは本当に縛っているか(変異テスト): 実装を壊して、テストが落ちるかを見た。

壊し方落ちたテスト
Retry-After を切り上げ→切り捨て(問A)TestRetryAfterIsCeiling
レート制限を認証の外側へ(問B)TestPerPrincipal TestRejectedDoesNotConsumeBudget
X-Forwarded-For を信用する(問C)TestAnonymousByIPAndIgnoresXFF
IP にポートを含めるTestAnonymousByIPAndIgnoresXFF
Mutex を外すTestConcurrentExactlyBurst(DATA RACE 9件、許可数も 50 でなくなる)
掃除を無効にするTestIdleBucketsAreSwept

6通りすべて検出(生き残った変異 0)。ただし、変異は私が思いついた6つだけで、網羅ではない。

速さ(go test -bench . -cpu 1,8。1回あたり。実測):

測るもの1 CPU8 CPU(並列)
allow 同じキー88.6 ns82.9 ns(直列呼び出し)
allow 同じキーを8並列で叩く72.6 ns210.1 ns
allow 10万キー×8並列187.5 ns427.1 ns
HTTP 1回(httptest)制限あり3489 ns, 20 allocs—
HTTP 1回(httptest)制限なし2833 ns, 18 allocs—
参考: x/time/rate の AllowN49.7 ns, 0 allocs—

読み取り: ミドルウェアが足すのは約 650 ns・2 allocs(strconv・キー連結)で、httptest 自体の 2.8 µs に隠れる程度。同じキーに8並列が殺到すると1個のロックで 210 ns(約 475 万回/秒)に落ちるが、実際のサービスでは1つのキーに秒間数百万回は来ないので、分割(シャーディング)は今日は不要と判断した。前提が変わる条件は後述。

x/time/rate との一致(実測。ratelimit.go の allow と rate.NewLimiter(...).AllowN(now, 1) に、乱数の到着間隔で20万回ずつ同じ列を流して判定を比べた):

rate / burst20万回中、判定が違った回数
0.4 / 30
1 / 50
20 / 400
0.05 / 10

また rate.Limiter に ReserveN させた待ち時間は rate 0.4・burst 1 で 2.5 秒(今日の Retry-After は切り上げて 3)。ライブラリは待ち時間を小数で返し、秒への丸めは使う側の責任、ということでもある。

5. 迷った他案と、採らなかった理由

案採らなかった理由前提が変わったら
A. x/time/rate を使い、キー表と掃除だけ書く実測で判定は一致し、速さも上(49.7 ns 対 88.6 ns)。本番ならこちらが本命。今日は「バケツの計算そのもの」を理解する課題なので自作した本番に載せるなら A に置き換える。契約テスト(main_test.go)はそのまま使える
B. 固定窓(毎分0秒でカウンタをリセット)窓の境目で2倍通る(59秒に Burst 回+0秒に Burst 回)。実装は最も簡単厳密さが要らず、窓ごとの課金・クォータ表示が目的なら B
C. 毎秒トークンを足す goroutine主体ごとに goroutine かタイマーが要り、掃除も複雑。計算を「来たとき」にまとめれば不要—
D. 守りの外側に置く(認証より前)主体がまだ分からず全員 IP キーになる(TestPerPrincipal が落ちる)。401 が予算を使う(TestRejectedDoesNotConsumeBudget が落ちる)総当たりの防止が目的なら、認証の外側に IP キーで別途置く(今日は扱わない。後述)
E. 分散(Redis で数える)複数台なら必須だが、今日の論点(位置・キー・断り方)と別の問題台数が2以上で「全体で毎秒 N」を守る必要があるとき
F. X-Forwarded-For を読むクライアントが自由に書けるので、読むと予算を無限に作れる信頼できるプロキシが最後に追記すると分かっているときに限り、右から数えて信頼する段数だけ読む

6. 問A〜Dの答え

  • 問A: TestRetryAfterIsCeiling。2.5 秒待ちを 2 と返すと、言われた通り2秒待ったクライアントがまた 429 になる
  • 問B: TestPerPrincipal と TestRejectedDoesNotConsumeBudget。外側では PrincipalFrom が空で全員が IP キーになり、不正トークンの連打が alice と同じ IP の予算を食う
  • 問C: ヘッダはクライアントが好きに書ける。リクエストごとに違う値を送れば、毎回新しいバケツが作られて一度も制限されない。読んでよいのは、自社のロードバランサだけが付けると保証できるとき
  • 問D: 100万個のバケツが map に残り続ける。1個あたりの大きさは今日は測っていない(未測定)。掃除は「放っておけば満タンに戻るものだけ」を消すので、制限の判定を変えずに増加を止められる(TestIdleBucketsAreSwept: 1000個 → 掃除後 1個)

🪜 Step-by-Step 解説

Step 1: バケツは2つの値で足りる

tokens と last。来たときに min(Burst, tokens + 経過秒 × PerSecond) を計算するだけで、タイマーも goroutine も要らない。now は関数の先頭で1回だけ取る(途中で時計を読み直すと、同じ呼び出しの中で時刻がずれる)。

Step 2: 断るときは「いつなら通るか」を返す

(1 - tokens) / PerSecond 秒。クライアントがこの秒数に従って待つだけで、無駄な再試行(429 の連打)が減る。丸めは切り上げ。

Step 3: キーは「誰が × どのルートを」

pattern + "\x00" + who。区切りに \x00 を使うのは、パターンにもホスト名にも現れないので、"a"+"bc" と "ab"+"c" が衝突しないから。

Step 4: 守りの内側に置く

認証 → 認可 → レート制限 → ハンドラ。外側に置くと主体が分からず(R3)、401 まで数えてしまう(R6)。内側に置く代償は「認証に失敗する連打は制限されない」こと。これは総当たり対策の別の層の仕事で、今日は扱わない。

Step 5: 決め忘れを型のゼロ値で止める

Limit{} は PerSecond == 0 なので valid() が偽になり、登録時に panic。無制限は Unlimited と書かせる。「書き忘れたら無制限」にならない。

Step 6: 掃除は「消しても結果が変わらないものだけ」

満タンのバケツは、消して次に満タンで作り直しても同じ。だから消してよい。時計は Now() ではなく注入した l.now から読むので、テストは fakeClock.Advance で10分進めて確かめられる。

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

  • 状態は1つの型に閉じ、ミドルウェアは薄い: 計算(allow)と HTTP(Middleware)を分けたので、計算だけをベンチ・突き合わせ・変異で検査できた。http.Handler を介さずに 20万回の突き合わせができたのはこの分離のおかげ
  • 時計を引数にする: Go では time.Now を直接呼ぶ関数はテストしにくい。func() time.Time を1つ渡すだけで足り、インターフェースを定義する必要はない(小さい依存は関数で渡す)
  • ゼロ値を「未定」に使う: Day 057 の Access と同じ。Go のゼロ値は「何もしない」ではなく「決めていない」として扱い、init 時に落とす
  • ライブラリを使うかを記録する: 自作は学習の素材であって、本番の推奨ではない。契約テストを残せば、後で x/time/rate に差し替えても検証が続く

🌐 他言語との比較

Go(今日)JavaPythonTypeScript / Node
実装の選択肢x/time/rate(キー表は自作)か自作Bucket4j、Resilience4j RateLimiterlimits、slowapi(FastAPI 用)、Django は django-ratelimitexpress-rate-limit、rate-limiter-flexible
並行の守りsync.Mutex + -race で検査AtomicReference の CAS が定番GIL があっても複数ワーカーでは共有できない単一スレッドなので不要。ただしプロセスをまたぐと共有できない
複数台自作が必要(Redis 等)Bucket4j は Redis・Hazelcast 連携ありlimits が Redis 対応rate-limiter-flexible が Redis 対応
注意1プロセス内だけの上限同左(分散版を選べる)ワーカー数だけ上限が増えるクラスタ・複数台で上限が台数倍になる

出典: 各ライブラリ名は手元知識で、参照日 2026-10-02 に公式ページの再確認はしていない。ライブラリの機能の細部は使う前に公式で確認すること。共通する落とし穴は「プロセスが増えると上限も増える」こと。

🏭 実務での使いどころ

  • 書き込み系だけ厳しくする: 今日の表のように、参照は毎秒20回、注文の作成は毎秒1回、とルートごとに桁を変える。事故(二重送信・暴走クライアント)の被害が書き込みに集中するため
  • 429 を返す側のログ: 429 は「障害」ではなく正常な動作。アラートに混ぜず、主体別の 429 率を見る。ある主体だけ 429 が多いなら、その連携先のバグか、上限が低すぎる
  • クライアント側: Retry-After に従う再試行を書く。従わない固定間隔の再試行は、制限をさらに悪化させる
  • ロードバランサの後ろ: 接続元 IP が全部ロードバランサの IP になる。自作する前に、RemoteAddr が何になるかを必ず確認する(F案の前提)

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
Retry-After に小数や切り捨てを返す計算結果をそのまま int()整数秒に切り上げる。小数は仕様上書けない
RemoteAddr をそのままキーにするhost:port の文字列だからポートは接続ごとに変わる。net.SplitHostPort で host だけ
X-Forwarded-For を無条件に信用プロキシ越しなら正しい値と思うクライアントが書ける。信頼できる段数だけ右から読む
毎秒補充する goroutine を立てる「毎秒トークンを足す」という説明をそのまま実装来たときにまとめて計算すれば要らない
掃除を書かない小さい負荷試験では気付かない主体が増え続けるサービスでは、map が単調に増える
time.Now() を直接呼ぶ楽だからテストが実時間を待つことになる。関数で注入する

🚧 扱わなかった範囲

  • 認証に失敗する連打(総当たり)の対策: 今日は 401 を数えない設計にした。防ぐには認証の外側に IP キーの別の制限が要る(D案の前提が変わった場合)
  • 複数台での共有(Redis など)、IPv6 の /64 単位でまとめる扱い、**RateLimit-* ヘッダ**(IETF のドラフトを再確認していないため断定しない)
  • バケツ1個あたりのメモリと、100万主体での実測(問Dは考え方のみで、数値は未測定)
  • x/time/rate の全機能(Wait・Reserve・動的な SetLimit など)。今日使ったのは AllowN のみ
  • シャーディング: 同じキーに8並列で 210 ns という実測までで、必要になる閾値は測っていない

🚀 次のステップ

  • 発展: Limiter を x/time/rate に差し替えて、main_test.go が無修正で通ることを確かめる(契約テストの確認)
  • 次回予告: 同テーマで「デバッグ」。ミドルウェアの順序・ResponseWriter の包み方・defer の位置が原因の、本番で起きた形のバグを3つ見つける

🎯 自己評価(解いた後に記入)

問A〜Dの予想と結果

自分の回答

気づき・メモ