📚 背景知識(読んでから問題へ)
Day 057 で、ルートごとに違う守りを「宣言表の1列」で選ぶ形にしました。今日はその表に 「上限」の列を1つ足し、レート制限ミドルウェアを書きます。論点は次の3つです。
- 何を数えるか: トークンバケット。バケツ(容量
Burst)に毎秒PerSecond個のトークンが溜まり、1リクエストで1個使う。空なら断る。「平均は毎秒 N 回まで、瞬間的には Burst 回まで」を1つの仕組みで表せる - 誰の予算か: ルートごと・主体ごとに別のバケツ。キーは「認証済みなら主体、そうでなければ接続元 IP」
- 守りのどこに置くか: 認証より外か内か。ここが今日の設計判断で、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。すべてテストになっている)
| # | 要件 | テスト |
|---|---|---|
| R1 | Burst 回まで即時に通し、超えたら 429 | TestBurstThen429WithRetryAfter |
| R2 | 429 に 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 |
| R7 | 429 にしたリクエストは本体のハンドラに届かない | TestLimitedRequestNeverReachesHandler |
| R8 | Limit のゼロ値(決め忘れ)は登録時に panic。無制限は Unlimited と明示 | TestUndecidedLimitPanics TestUnlimitedIsExplicit |
| R9 | 同時アクセスでも許可数がちょうど Burst(-race で通る) | TestConcurrentExactlyBurst |
| R10 | 来なくなった主体のバケツは消える。時計が戻っても補充しない | TestIdleBucketsAreSwept TestClockGoingBackwardsDoesNotMintTokens |
課題
ratelimit.goを実装する(Limitの検査、Limiter、Middleware)server.goのTODOで、l.Middleware(...)をguardsのどこに入れるか決める。R6 と R3 がその位置を決める- 次の問いに、実装前に予想を書く - 問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)
- 状態は
Limiter1つ(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 CPU | 8 CPU(並列) |
|---|---|---|
allow 同じキー | 88.6 ns | 82.9 ns(直列呼び出し) |
allow 同じキーを8並列で叩く | 72.6 ns | 210.1 ns |
allow 10万キー×8並列 | 187.5 ns | 427.1 ns |
HTTP 1回(httptest)制限あり | 3489 ns, 20 allocs | — |
HTTP 1回(httptest)制限なし | 2833 ns, 18 allocs | — |
参考: x/time/rate の AllowN | 49.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 / burst | 20万回中、判定が違った回数 |
|---|---|
| 0.4 / 3 | 0 |
| 1 / 5 | 0 |
| 20 / 40 | 0 |
| 0.05 / 1 | 0 |
また 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(今日) | Java | Python | TypeScript / Node | |
|---|---|---|---|---|
| 実装の選択肢 | x/time/rate(キー表は自作)か自作 | Bucket4j、Resilience4j RateLimiter | limits、slowapi(FastAPI 用)、Django は django-ratelimit | express-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つ見つける