iT邦幫忙

2025 iThome 鐵人賽

DAY 7
1
Software Development

用 Golang + Elasticsearch + Kubernetes 打造雲原生搜尋服務系列 第 7

Day 7 - 錯誤策略:%w 包裝、分類重試(退避 + 抖動)

  • 分享至 

  • xImage
  •  

喵一眼今日重點:

  • %w 包裝錯誤(可疊代追蹤來源)
  • 分類重試(什麼錯誤該重試、什麼不該)
  • 退避 + 抖動(exponential backoff with jitter)
  • 搭配 context(可被取消、守住時限)

將 Day 6 的模擬 timeout 取消、search timeout test 改為可選測試,Day 6 的程式碼完整變更可參考 Pull Request

今天新增工具層(errors.goretry.go),並用測試驗證策略。


實務上,失敗不一定等於結束。有些錯誤「暫時性」(網路抖動、下游短暫 5xx)適合 重試;有些錯誤「邏輯性」(4xx、參數錯)應 立即失敗

此外,重試需要等一段時間,讓下游有時間恢復,避免雪崩,也就是 退避(exponential backoff)。

重試需要避免所有人同時重試,這樣依然會形成流量尖峰,所以加上一點隨機性,我們稱為 抖動(jitter)

而所有等待都要尊重 context,才能在 timeout 或使用者取消時立刻停手。


Step 1:錯誤包裝(%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 可往內找)。
  • IsRetryable:統一判斷規則(timeout/temporary/net.Error/HTTP 5xx/429 → 重試;4xx → 不重試)。

Step 2:退避 + 抖動(Backoff)

新增 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
}

重點:

  • 指數退避:第 n 次等待 Base * Factor^n,封頂 Max
  • 抖動:避免「集體同時重試」造成雪崩。
    • 「抖動」(英文 flakinessflaky tests,也有人說 timing jitter)是指測試結果不穩定,有時綠、有時紅,跟程式邏輯本身無關,而是受到「時間因素」影響。
  • 尊重 context:等待中也能被取消,立即返回。

Step 3:單元測試

新增 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)
	}
}


Step 4:如何在未來串接下游時使用

今天不改你的 /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:任何一段等待都能被取消。

Step 5:執行測試

go test -v ./...

預期結果
https://ithelp.ithome.com.tw/upload/images/20250921/20138331yrozj2UvAR.png


小結

今天我們建立了可複用的錯誤策略基礎:

  • %w 讓錯誤可追蹤、可判別(errors.Is/As
  • IsRetryable 統一錯誤分類規則
  • Retry:指數退避 + 抖動,且完全 context-aware
  • 以單元測試保證:
    • 退避曲線正確
    • 可重試 → 直到成功
    • 不可重試 → 立刻返回
    • context 取消 → 立即停止

這套工具不侵入原有 handler,未來接 ES 就能直接套上去。


上一篇
Day 6 - context/timeout:防止外呼卡死
下一篇
Day 8 - 微基準:用 go test -bench 建立效能基線
系列文
用 Golang + Elasticsearch + Kubernetes 打造雲原生搜尋服務8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言