喵一眼今日重點:
%w
包裝錯誤(可疊代追蹤來源)將 Day 6 的模擬 timeout 取消、search timeout test 改為可選測試,Day 6 的程式碼完整變更可參考 Pull Request
今天新增工具層(errors.go
、retry.go
),並用測試驗證策略。
實務上,失敗不一定等於結束。有些錯誤「暫時性」(網路抖動、下游短暫 5xx)適合 重試;有些錯誤「邏輯性」(4xx、參數錯)應 立即失敗。
此外,重試需要等一段時間,讓下游有時間恢復,避免雪崩,也就是 退避(exponential backoff)。
重試需要避免所有人同時重試,這樣依然會形成流量尖峰,所以加上一點隨機性,我們稱為 抖動(jitter)。
而所有等待都要尊重 context,才能在 timeout 或使用者取消時立刻停手。
%w
)與分類新增 errors.go
:
package main
import (
"errors"
"fmt"
"net"
"net/http"
)
var (
// 範例:語意化錯誤
ErrTimeout = errors.New("timeout")
ErrTemporary = errors.New("temporary")
ErrBadRequest = errors.New("bad request")
)
// Wrap: 以操作名稱包裝錯誤(可多層疊代)
func Wrap(op string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", op, err)
}
// IsRetryable: 判斷是否「值得重試」
func IsRetryable(err error) bool {
if err == nil {
return false
}
// 1) 內建語意
if errors.Is(err, ErrTimeout) || errors.Is(err, ErrTemporary) {
return true
}
if errors.Is(err, ErrBadRequest) {
return false
}
// 2) net.Error: Timeout 或 Temporary
var ne net.Error
if errors.As(err, &ne) {
return ne.Timeout() || ne.Temporary()
}
// 3) HTTP 狀態碼(若錯誤內含)
var he *HTTPStatusError
if errors.As(err, &he) {
// 5xx 可重試,429(Too Many Requests)也可視情況重試
if he.StatusCode == http.StatusTooManyRequests {
return true
}
return he.StatusCode >= 500 && he.StatusCode <= 599
}
// 預設保守:不重試
return false
}
// HTTPStatusError:把下游回來的 HTTP 狀態碼帶進錯誤
type HTTPStatusError struct {
StatusCode int
Body string
}
func (e *HTTPStatusError) Error() string {
return fmt.Sprintf("http status %d: %s", e.StatusCode, e.Body)
}
重點:
fmt.Errorf("%w")
讓你能在多層呼叫中保留錯誤鏈(errors.Is/As
可往內找)。timeout/temporary/net.Error/HTTP 5xx/429
→ 重試;4xx → 不重試)。新增 retry.go
:
package main
import (
"context"
"errors"
"math"
"math/rand"
"time"
)
type Backoff struct {
Base time.Duration // 初始等待,例如 50ms
Max time.Duration // 上限,例如 2s
Factor float64 // 指數成長倍率,例如 2.0
Jitter float64 // 抖動比例 0~1,例如 0.2 代表 ±20%
// 測試用:可注入 rand 以固定結果
r *rand.Rand
}
func (b Backoff) withRand() Backoff {
if b.r == nil {
b2 := b
b2.r = rand.New(rand.NewSource(time.Now().UnixNano()))
return b2
}
return b
}
// 第 n 次(從 0 起算)的等待時間
func (b Backoff) Duration(attempt int) time.Duration {
if attempt < 0 {
attempt = 0
}
// 指數成長
mult := math.Pow(b.Factor, float64(attempt))
d := time.Duration(float64(b.Base) * mult)
if d > b.Max {
d = b.Max
}
// 抖動:±Jitter
if b.Jitter > 0 {
b2 := b.withRand()
j := (b2.r.Float64()*2 - 1) * b.Jitter // [-Jitter, +Jitter]
d = time.Duration(float64(d) * (1 + j))
}
if d < 0 {
d = 0
}
return d
}
// Retry: 以 backoff + jitter 重試 fn;遇到非重試錯誤、或 ctx 結束則返回
func Retry(ctx context.Context, maxAttempts int, b Backoff, fn func(context.Context) error, shouldRetry func(error) bool) error {
if maxAttempts <= 0 {
return errors.New("maxAttempts must be > 0")
}
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
// 先檢查是否已取消
select {
case <-ctx.Done():
return Wrap("retry canceled", ctx.Err())
default:
}
err := fn(ctx)
if err == nil {
return nil
}
lastErr = err
if !shouldRetry(err) || attempt == maxAttempts-1 {
return lastErr
}
wait := b.Duration(attempt)
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return Wrap("retry canceled", ctx.Err())
case <-timer.C:
}
}
return lastErr
}
重點:
Base * Factor^n
,封頂 Max
。新增 retry_test.go
:
package main
import (
"context"
"errors"
"math/rand"
"net"
"net/http"
"testing"
"time"
)
func TestBackoff_NoJitter(t *testing.T) {
b := Backoff{Base: 10 * time.Millisecond, Max: 80 * time.Millisecond, Factor: 2, Jitter: 0}
got := []time.Duration{
b.Duration(0),
b.Duration(1),
b.Duration(2),
b.Duration(3),
b.Duration(4), // 封頂
}
want := []time.Duration{
10 * time.Millisecond,
20 * time.Millisecond,
40 * time.Millisecond,
80 * time.Millisecond,
80 * time.Millisecond,
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("attempt %d: got %v, want %v", i, got[i], want[i])
}
}
}
func TestIsRetryable_HTTP(t *testing.T) {
// 5xx
err := &HTTPStatusError{StatusCode: 502}
if !IsRetryable(err) {
t.Fatal("502 should be retryable")
}
// 4xx
err = &HTTPStatusError{StatusCode: 400}
if IsRetryable(err) {
t.Fatal("400 should not be retryable")
}
// 429
err = &HTTPStatusError{StatusCode: http.StatusTooManyRequests}
if !IsRetryable(err) {
t.Fatal("429 should be retryable")
}
}
type tmpNetErr struct{}
func (tmpNetErr) Error() string { return "net temp" }
func (tmpNetErr) Timeout() bool { return false }
func (tmpNetErr) Temporary() bool { return true }
type timeoutNetErr struct{}
func (timeoutNetErr) Error() string { return "net timeout" }
func (timeoutNetErr) Timeout() bool { return true }
func (timeoutNetErr) Temporary() bool { return false }
func TestIsRetryable_netError(t *testing.T) {
var e net.Error = tmpNetErr{}
if !IsRetryable(e) {
t.Fatal("temporary net.Error should be retryable")
}
e = timeoutNetErr{}
if !IsRetryable(e) {
t.Fatal("timeout net.Error should be retryable")
}
}
func TestRetry_SucceedsAfterRetries(t *testing.T) {
// 固定亂數以穩定 jitter(或直接 Jitter=0)
b := Backoff{Base: 1 * time.Millisecond, Max: 3 * time.Millisecond, Factor: 2, Jitter: 0, r: rand.New(rand.NewSource(1))}
ctx := context.Background()
attempts := 0
fn := func(context.Context) error {
attempts++
if attempts < 3 {
return ErrTemporary // 前兩次暫時性錯誤
}
return nil
}
err := Retry(ctx, 5, b, fn, IsRetryable)
if err != nil {
t.Fatalf("expected success, got error %v", err)
}
if attempts != 3 {
t.Fatalf("attempts got %d, want 3", attempts)
}
}
func TestRetry_StopsOnNonRetryable(t *testing.T) {
b := Backoff{Base: 1 * time.Millisecond, Max: 2 * time.Millisecond, Factor: 2, Jitter: 0}
ctx := context.Background()
attempts := 0
fn := func(context.Context) error {
attempts++
return ErrBadRequest // 不可重試
}
err := Retry(ctx, 5, b, fn, IsRetryable)
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("want ErrBadRequest, got %v", err)
}
if attempts != 1 {
t.Fatalf("attempts got %d, want 1", attempts)
}
}
func TestRetry_CancelContext(t *testing.T) {
b := Backoff{Base: 50 * time.Millisecond, Max: 50 * time.Millisecond, Factor: 2, Jitter: 0}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
fn := func(context.Context) error { return ErrTemporary }
err := Retry(ctx, 10, b, fn, IsRetryable)
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("want context.DeadlineExceeded, got %v", err)
}
}
今天不改你的 /search handler,保持 Day 6 的教學情境。
未來 Day 12 你導入
SearchService
/接 ES client 時,可這樣套用:
// pseudo code
err := Retry(ctx, 4,
Backoff{Base: 100 * time.Millisecond, Max: 2 * time.Second, Factor: 2, Jitter: 0.2},
func(ctx context.Context) error {
resp, err := esClient.Search(ctx, q) // 一定要把 ctx 傳下去
if err != nil {
return Wrap("es search", err)
}
if resp.StatusCode >= 400 {
return Wrap("es search", &HTTPStatusError{StatusCode: resp.StatusCode, Body: resp.Body})
}
return nil
},
IsRetryable,
)
重點:
- 分類:只對 可重試 的錯誤進行重試(Timeout/5xx/429/暫時性網路錯)。
- 退避 + 抖動:保護下游,避免放大災情。
- 尊重 context:任何一段等待都能被取消。
go test -v ./...
預期結果
今天我們建立了可複用的錯誤策略基礎:
%w
讓錯誤可追蹤、可判別(errors.Is/As
)這套工具不侵入原有 handler,未來接 ES 就能直接套上去。