feat(report): improve daily intelligence UX and price tracking
This commit is contained in:
@@ -4,19 +4,43 @@ package retry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type temporaryError interface {
|
||||
Temporary() bool
|
||||
}
|
||||
|
||||
type timeoutError interface {
|
||||
Timeout() bool
|
||||
}
|
||||
|
||||
type HTTPStatusError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e HTTPStatusError) Error() string {
|
||||
if e.Body == "" {
|
||||
return fmt.Sprintf("http status %d", e.StatusCode)
|
||||
}
|
||||
return fmt.Sprintf("http status %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// Strategy 重试策略
|
||||
type Strategy struct {
|
||||
MaxRetries int // 最大重试次数(0=不重试)
|
||||
BaseDelay time.Duration // 基础延迟
|
||||
MaxDelay time.Duration // 最大延迟上限
|
||||
Multiplier float64 // 乘数(默认2.0)
|
||||
Jitter bool // 是否添加随机抖动
|
||||
Retryable func(error) bool // 判断错误是否可重试
|
||||
MaxRetries int // 最大重试次数(0=不重试)
|
||||
BaseDelay time.Duration // 基础延迟
|
||||
MaxDelay time.Duration // 最大延迟上限
|
||||
Multiplier float64 // 乘数(默认2.0)
|
||||
Jitter bool // 是否添加随机抖动
|
||||
Retryable func(error) bool // 判断错误是否可重试
|
||||
}
|
||||
|
||||
// DefaultStrategy 返回默认重试策略
|
||||
@@ -31,36 +55,89 @@ func DefaultStrategy() Strategy {
|
||||
}
|
||||
}
|
||||
|
||||
// IsRetryable 默认重试判定:网络错误、超时、5xx状态码等可重试
|
||||
// IsRetryable 默认重试判定:仅临时网络错误、429、5xx 等可重试
|
||||
func IsRetryable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// 这里可以扩展更多错误类型判定
|
||||
return true
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
|
||||
var statusErr HTTPStatusError
|
||||
if errors.As(err, &statusErr) {
|
||||
return statusErr.StatusCode == 429 || (statusErr.StatusCode >= 500 && statusErr.StatusCode < 600)
|
||||
}
|
||||
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return true
|
||||
}
|
||||
|
||||
message := strings.ToLower(err.Error())
|
||||
if strings.Contains(message, "json 解析失败") ||
|
||||
strings.Contains(message, "invalid character") ||
|
||||
strings.Contains(message, "unmarshal") ||
|
||||
strings.Contains(message, "decode") ||
|
||||
strings.Contains(message, "schema") {
|
||||
return false
|
||||
}
|
||||
|
||||
var tempErr temporaryError
|
||||
if errors.As(err, &tempErr) && tempErr.Temporary() {
|
||||
return true
|
||||
}
|
||||
|
||||
var timeoutErr timeoutError
|
||||
if errors.As(err, &timeoutErr) && timeoutErr.Timeout() {
|
||||
return true
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary()) {
|
||||
return true
|
||||
}
|
||||
|
||||
retriableMarkers := []string{
|
||||
"transport closed",
|
||||
"connection reset",
|
||||
"connection refused",
|
||||
"tls handshake timeout",
|
||||
"i/o timeout",
|
||||
"no such host",
|
||||
"temporarily unavailable",
|
||||
"too many requests",
|
||||
"rate limit",
|
||||
}
|
||||
for _, marker := range retriableMarkers {
|
||||
if strings.Contains(message, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Do 执行带重试的操作
|
||||
func Do(ctx context.Context, strategy Strategy, fn func() error) error {
|
||||
var lastErr error
|
||||
|
||||
|
||||
for attempt := 0; attempt <= strategy.MaxRetries; attempt++ {
|
||||
if err := fn(); err != nil {
|
||||
lastErr = err
|
||||
|
||||
|
||||
// 不判断最后一次是否需要重试
|
||||
if attempt == strategy.MaxRetries {
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
// 检查是否可重试
|
||||
if strategy.Retryable != nil && !strategy.Retryable(err) {
|
||||
return fmt.Errorf("non-retryable error on attempt %d: %w", attempt+1, err)
|
||||
}
|
||||
|
||||
|
||||
// 计算退避延迟
|
||||
delay := calculateDelay(strategy, attempt)
|
||||
|
||||
|
||||
// 检查上下文是否已取消
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -72,7 +149,7 @@ func Do(ctx context.Context, strategy Strategy, fn func() error) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return fmt.Errorf("all %d attempts failed, last error: %w", strategy.MaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
@@ -80,18 +157,18 @@ func Do(ctx context.Context, strategy Strategy, fn func() error) error {
|
||||
func calculateDelay(s Strategy, attempt int) time.Duration {
|
||||
// 指数退避: base * multiplier^attempt
|
||||
delay := float64(s.BaseDelay) * math.Pow(s.Multiplier, float64(attempt))
|
||||
|
||||
|
||||
// 添加上限
|
||||
if max := float64(s.MaxDelay); delay > max {
|
||||
delay = max
|
||||
}
|
||||
|
||||
|
||||
// 添加抖动(±25%)
|
||||
if s.Jitter {
|
||||
jitter := delay * 0.25
|
||||
delay = delay - jitter + (jitter * 2 * float64(time.Now().Nanosecond()%1000) / 1000)
|
||||
}
|
||||
|
||||
|
||||
return time.Duration(delay)
|
||||
}
|
||||
|
||||
@@ -99,31 +176,31 @@ func calculateDelay(s Strategy, attempt int) time.Duration {
|
||||
func DoWithResult[T any](ctx context.Context, strategy Strategy, fn func() (T, error)) (T, error) {
|
||||
var zero T
|
||||
var lastErr error
|
||||
|
||||
|
||||
for attempt := 0; attempt <= strategy.MaxRetries; attempt++ {
|
||||
result, err := fn()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
lastErr = err
|
||||
if attempt == strategy.MaxRetries {
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
if strategy.Retryable != nil && !strategy.Retryable(err) {
|
||||
return zero, fmt.Errorf("non-retryable error on attempt %d: %w", attempt+1, err)
|
||||
}
|
||||
|
||||
|
||||
delay := calculateDelay(strategy, attempt)
|
||||
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return zero, fmt.Errorf("context cancelled after attempt %d: %w", attempt+1, ctx.Err())
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return zero, fmt.Errorf("all %d attempts failed, last error: %w", strategy.MaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
@@ -139,7 +216,7 @@ func DoWithMetrics(ctx context.Context, strategy Strategy, fn func() error) (Met
|
||||
m := Metrics{}
|
||||
var lastErr error
|
||||
start := time.Now()
|
||||
|
||||
|
||||
for attempt := 0; attempt <= strategy.MaxRetries; attempt++ {
|
||||
m.Attempts = attempt + 1
|
||||
if err := fn(); err != nil {
|
||||
@@ -164,7 +241,7 @@ func DoWithMetrics(ctx context.Context, strategy Strategy, fn func() error) (Met
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
m.TotalDelay = time.Since(start)
|
||||
return m, fmt.Errorf("all %d attempts failed, last error: %w", strategy.MaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
@@ -4,19 +4,31 @@ package retry
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func alwaysRetry(error) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func neverRetry(error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func TestDo_Success(t *testing.T) {
|
||||
strategy := DefaultStrategy()
|
||||
callCount := 0
|
||||
|
||||
|
||||
err := Do(context.Background(), strategy, func() error {
|
||||
callCount++
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
@@ -32,10 +44,10 @@ func TestDo_RetryThenSuccess(t *testing.T) {
|
||||
MaxDelay: 100 * time.Millisecond,
|
||||
Multiplier: 2.0,
|
||||
Jitter: false,
|
||||
Retryable: IsRetryable,
|
||||
Retryable: alwaysRetry,
|
||||
}
|
||||
callCount := 0
|
||||
|
||||
|
||||
err := Do(context.Background(), strategy, func() error {
|
||||
callCount++
|
||||
if callCount < 3 {
|
||||
@@ -43,7 +55,7 @@ func TestDo_RetryThenSuccess(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
@@ -59,16 +71,16 @@ func TestDo_MaxRetriesExceeded(t *testing.T) {
|
||||
MaxDelay: 50 * time.Millisecond,
|
||||
Multiplier: 2.0,
|
||||
Jitter: false,
|
||||
Retryable: IsRetryable,
|
||||
Retryable: alwaysRetry,
|
||||
}
|
||||
callCount := 0
|
||||
expectedErr := errors.New("persistent error")
|
||||
|
||||
|
||||
err := Do(context.Background(), strategy, func() error {
|
||||
callCount++
|
||||
return expectedErr
|
||||
})
|
||||
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -84,15 +96,15 @@ func TestDo_NonRetryableError(t *testing.T) {
|
||||
MaxDelay: 100 * time.Millisecond,
|
||||
Multiplier: 2.0,
|
||||
Jitter: false,
|
||||
Retryable: func(err error) bool { return false }, // 任何错误都不重试
|
||||
Retryable: neverRetry, // 任何错误都不重试
|
||||
}
|
||||
callCount := 0
|
||||
|
||||
|
||||
err := Do(context.Background(), strategy, func() error {
|
||||
callCount++
|
||||
return errors.New("non-retryable")
|
||||
})
|
||||
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -101,6 +113,48 @@ func TestDo_NonRetryableError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryableRejectsContextCancellation(t *testing.T) {
|
||||
if IsRetryable(context.Canceled) {
|
||||
t.Fatal("context.Canceled should not be retryable")
|
||||
}
|
||||
if IsRetryable(context.DeadlineExceeded) {
|
||||
t.Fatal("context.DeadlineExceeded should not be retryable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryableRejectsPermanentHTTPStatus(t *testing.T) {
|
||||
err := HTTPStatusError{StatusCode: http.StatusForbidden, Body: "forbidden"}
|
||||
if IsRetryable(err) {
|
||||
t.Fatal("403 should not be retryable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryableAllowsTemporaryNetworkAndServerErrors(t *testing.T) {
|
||||
err := HTTPStatusError{StatusCode: http.StatusBadGateway, Body: "bad gateway"}
|
||||
if !IsRetryable(err) {
|
||||
t.Fatal("502 should be retryable")
|
||||
}
|
||||
|
||||
netErr := &net.DNSError{IsTemporary: true}
|
||||
if !IsRetryable(netErr) {
|
||||
t.Fatal("temporary network errors should be retryable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryableRejectsJSONParseErrors(t *testing.T) {
|
||||
err := errors.New("JSON 解析失败: invalid character")
|
||||
if IsRetryable(err) {
|
||||
t.Fatal("JSON parse errors should not be retryable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryableAllowsUnexpectedEOF(t *testing.T) {
|
||||
err := fmt.Errorf("transport closed: %w", io.ErrUnexpectedEOF)
|
||||
if !IsRetryable(err) {
|
||||
t.Fatal("unexpected EOF should be retryable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_ContextCancellation(t *testing.T) {
|
||||
strategy := Strategy{
|
||||
MaxRetries: 3,
|
||||
@@ -108,18 +162,18 @@ func TestDo_ContextCancellation(t *testing.T) {
|
||||
MaxDelay: 5 * time.Second,
|
||||
Multiplier: 2.0,
|
||||
Jitter: false,
|
||||
Retryable: IsRetryable,
|
||||
Retryable: alwaysRetry,
|
||||
}
|
||||
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
|
||||
callCount := 0
|
||||
err := Do(ctx, strategy, func() error {
|
||||
callCount++
|
||||
return errors.New("error")
|
||||
})
|
||||
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -138,10 +192,10 @@ func TestDoWithResult(t *testing.T) {
|
||||
MaxDelay: 50 * time.Millisecond,
|
||||
Multiplier: 2.0,
|
||||
Jitter: false,
|
||||
Retryable: IsRetryable,
|
||||
Retryable: alwaysRetry,
|
||||
}
|
||||
callCount := 0
|
||||
|
||||
|
||||
result, err := DoWithResult(context.Background(), strategy, func() (string, error) {
|
||||
callCount++
|
||||
if callCount < 2 {
|
||||
@@ -149,7 +203,7 @@ func TestDoWithResult(t *testing.T) {
|
||||
}
|
||||
return "success", nil
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
@@ -168,9 +222,9 @@ func TestDoWithMetrics(t *testing.T) {
|
||||
MaxDelay: 100 * time.Millisecond,
|
||||
Multiplier: 2.0,
|
||||
Jitter: false,
|
||||
Retryable: IsRetryable,
|
||||
Retryable: alwaysRetry,
|
||||
}
|
||||
|
||||
|
||||
// 成功场景
|
||||
m, err := DoWithMetrics(context.Background(), strategy, func() error {
|
||||
return nil
|
||||
@@ -184,7 +238,7 @@ func TestDoWithMetrics(t *testing.T) {
|
||||
if m.Attempts != 1 {
|
||||
t.Errorf("expected 1 attempt, got %d", m.Attempts)
|
||||
}
|
||||
|
||||
|
||||
// 失败场景
|
||||
m2, err := DoWithMetrics(context.Background(), strategy, func() error {
|
||||
return errors.New("always fails")
|
||||
@@ -207,7 +261,7 @@ func TestCalculateDelay(t *testing.T) {
|
||||
Multiplier: 2.0,
|
||||
Jitter: false,
|
||||
}
|
||||
|
||||
|
||||
tests := []struct {
|
||||
attempt int
|
||||
min time.Duration
|
||||
@@ -219,7 +273,7 @@ func TestCalculateDelay(t *testing.T) {
|
||||
{3, 8 * time.Second, 8 * time.Second},
|
||||
{4, 10 * time.Second, 10 * time.Second}, // 达到上限
|
||||
}
|
||||
|
||||
|
||||
for _, tt := range tests {
|
||||
delay := calculateDelay(strategy, tt.attempt)
|
||||
if delay < tt.min || delay > tt.max {
|
||||
@@ -236,7 +290,7 @@ func BenchmarkDo(b *testing.B) {
|
||||
Multiplier: 0,
|
||||
Jitter: false,
|
||||
}
|
||||
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Do(context.Background(), strategy, func() error {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user