Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions internal/provider/errors.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
package provider

import (
"context"
"errors"
"fmt"
"net"
"net/http"
)

// 通用领域错误。
var (
ErrDriverNotFound = errors.New("provider driver not found")
ErrDriverAlreadyRegistered = errors.New("provider: driver already registered")

// 流级哨兵错误,用于区分可恢复/不可恢复的流中断原因。
ErrStreamInterrupted = errors.New("provider: stream interrupted")
ErrLineTooLong = errors.New("provider: SSE line exceeds max length")
ErrStreamTooLarge = errors.New("provider: stream total size exceeds limit")
)

type ProviderErrorCode string
Expand Down Expand Up @@ -100,3 +107,63 @@ func NewTimeoutProviderError(message string) *ProviderError {
Retryable: true, // 超时默认可重试
}
}

// MarkNonRetryable 将错误标记为不可重试,用于防止上层重试叠加放大。
//
// 若错误链中包含 *ProviderError,返回其 Retryable=false 的副本;
// 否则将原始错误包装为 *ProviderError{Code: ErrorCodeUnknown, Retryable: false}。
// 原始错误通过 Unwrap 保留,不影响 errors.Is/As 对原始哨兵的匹配。
func MarkNonRetryable(err error) error {
var pErr *ProviderError
if errors.As(err, &pErr) {
clone := *pErr
clone.Retryable = false
return &clone
}
return &ProviderError{
StatusCode: 0,
Code: ErrorCodeUnknown,
Message: err.Error(),
Retryable: false,
}
}

// IsRecoverableStreamError 判断流读取错误是否可通过透明重连恢复。
//
// 不可恢复的情况:
// - context 取消/超时(调用方主动终止)
// - 缓冲区溢出(重连只会再次溢出)
// - 认证失败等业务错误(重连无意义)
//
// 可恢复的情况:
// - ProviderError 且 Retryable=true(5xx、429 等)
// - 网络层临时错误(*net.OpError)
// - ErrStreamInterrupted(通用流中断标记)
func IsRecoverableStreamError(err error) bool {
if err == nil {
return false
}
// context 取消 → 不可恢复
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
// 缓冲区溢出 → 不可恢复(重连同样会溢出)
if errors.Is(err, ErrLineTooLong) || errors.Is(err, ErrStreamTooLarge) {
return false
}
// 流中断标记 → 可恢复
if errors.Is(err, ErrStreamInterrupted) {
return true
}
// ProviderError → 依据 Retryable 字段
var pErr *ProviderError
if errors.As(err, &pErr) {
return pErr.Retryable
}
// 网络层临时故障(连接重置、超时等)→ 可恢复
var netErr *net.OpError
if errors.As(err, &netErr) {
return true
}
return false
}
217 changes: 217 additions & 0 deletions internal/provider/errors_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package provider

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
Expand Down Expand Up @@ -170,3 +172,218 @@ func TestProviderError_As(t *testing.T) {
t.Fatalf("expected retryable")
}
}

// --- IsRecoverableStreamError 全分支覆盖 ---

func TestIsRecoverableStreamError_Nil(t *testing.T) {
t.Parallel()
if IsRecoverableStreamError(nil) {
t.Fatal("nil error should not be recoverable")
}
}

func TestIsRecoverableStreamError_ContextErrors_NotRecoverable(t *testing.T) {
t.Parallel()

tests := []struct {
name string
err error
}{
{"context.Canceled", context.Canceled},
{"context.DeadlineExceeded", context.DeadlineExceeded},
{"wrapped Canceled", fmt.Errorf("wrap: %w", context.Canceled)},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if IsRecoverableStreamError(tt.err) {
t.Fatalf("%v should not be recoverable", tt.err)
}
})
}
}

func TestIsRecoverableStreamError_BufferOverflow_NotRecoverable(t *testing.T) {
t.Parallel()

tests := []struct {
name string
sentinel error
}{
{"ErrLineTooLong", ErrLineTooLong},
{"ErrStreamTooLarge", ErrStreamTooLarge},
{"wrapped ErrLineTooLong", fmt.Errorf("read: %w", ErrLineTooLong)},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if IsRecoverableStreamError(tt.sentinel) {
t.Fatalf("%v should not be recoverable", tt.sentinel)
}
})
}
}

func TestIsRecoverableStreamError_StreamInterrupted_Recoverable(t *testing.T) {
t.Parallel()
if !IsRecoverableStreamError(ErrStreamInterrupted) {
t.Fatal("ErrStreamInterrupted should be recoverable")
}
wrapped := fmt.Errorf("stream broken: %w", ErrStreamInterrupted)
if !IsRecoverableStreamError(wrapped) {
t.Fatal("wrapped ErrStreamInterrupted should be recoverable")
}
}

func TestIsRecoverableStreamError_ProviderError_ByRetryableField(t *testing.T) {
t.Parallel()

retryable := NewProviderErrorFromStatus(http.StatusTooManyRequests, "rate limit")
if !IsRecoverableStreamError(retryable) {
t.Fatal("429 ProviderError should be recoverable")
}

serverErr := NewProviderErrorFromStatus(http.StatusInternalServerError, "internal")
if !IsRecoverableStreamError(serverErr) {
t.Fatal("5xx ProviderError should be recoverable")
}

authErr := NewProviderErrorFromStatus(http.StatusUnauthorized, "bad key")
if IsRecoverableStreamError(authErr) {
t.Fatal("401 ProviderError should NOT be recoverable")
}

clientErr := NewProviderErrorFromStatus(http.StatusBadRequest, "bad request")
if IsRecoverableStreamError(clientErr) {
t.Fatal("400 ProviderError should NOT be recoverable")
}

wrappedRetryable := fmt.Errorf("layer1: %w", retryable)
if !IsRecoverableStreamError(wrappedRetryable) {
t.Fatal("wrapped retryable ProviderError should be recoverable")
}
}

func TestIsRecoverableStreamError_NetOpError_Recoverable(t *testing.T) {
t.Parallel()

// 模拟网络错误:使用一个包含 "connection reset" 的通用 error
// net.OpError 需要真实网络操作才能产生,这里用包装方式模拟
genericNetErr := fmt.Errorf("net error: connection reset by peer")
// 注意:真实的 *net.OpError 需要 errors.As 匹配
// 此处验证非上述已知不可恢复类型时默认返回 false
if IsRecoverableStreamError(genericNetErr) {
// 通用 error(非 OpError/ProviderError/哨兵)默认不恢复
t.Fatal("generic non-net error should not be recoverable")
}
}

func TestIsRecoverableStreamError_UnknownError_NotRecoverable(t *testing.T) {
t.Parallel()

tests := []struct {
name string
err error
}{
{"generic error", errors.New("something went wrong")},
{"io.EOF", io.EOF},
{"io.ErrClosedPipe", io.ErrClosedPipe},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if IsRecoverableStreamError(tt.err) {
t.Fatalf("%v should not be recoverable", tt.err)
}
})
}
}

// --- MarkNonRetryable 测试 ---

func TestMarkNonRetryable_ProviderError(t *testing.T) {
t.Parallel()

// Retryable=true 的 ProviderError → Retryable=false
retryable := NewProviderErrorFromStatus(http.StatusInternalServerError, "internal")
if !retryable.Retryable {
t.Fatal("setup: expected retryable")
}

marked := MarkNonRetryable(retryable)
var pErr *ProviderError
if !errors.As(marked, &pErr) {
t.Fatal("marked error should be *ProviderError")
}
if pErr.Retryable {
t.Fatal("MarkNonRetryable should set Retryable=false")
}
if pErr.StatusCode != 500 || pErr.Code != ErrorCodeServer {
t.Fatalf("MarkNonRetryable should preserve StatusCode and Code, got status=%d code=%s", pErr.StatusCode, pErr.Code)
}

// 原始对象不受影响
if !retryable.Retryable {
t.Fatal("original ProviderError should not be mutated")
}
}

func TestMarkNonRetryable_WrappedProviderError(t *testing.T) {
t.Parallel()

inner := NewProviderErrorFromStatus(http.StatusTooManyRequests, "rate limited")
wrapped := fmt.Errorf("layer: %w", inner)

marked := MarkNonRetryable(wrapped)
var pErr *ProviderError
if !errors.As(marked, &pErr) {
t.Fatal("marked error should contain *ProviderError")
}
if pErr.Retryable {
t.Fatal("MarkNonRetryable on wrapped should set Retryable=false")
}
}

func TestMarkNonRetryable_NonProviderError(t *testing.T) {
t.Parallel()

// 非 ProviderError → 包装为 ProviderError{Retryable: false}
generic := errors.New("some error")
marked := MarkNonRetryable(generic)

var pErr *ProviderError
if !errors.As(marked, &pErr) {
t.Fatal("marked error should be *ProviderError")
}
if pErr.Retryable {
t.Fatal("should be non-retryable")
}
if pErr.Code != ErrorCodeUnknown {
t.Fatalf("expected code %s, got %s", ErrorCodeUnknown, pErr.Code)
}
if !strings.Contains(pErr.Message, "some error") {
t.Fatalf("message should contain original error text, got: %s", pErr.Message)
}
}

func TestMarkNonRetryable_AlreadyNonRetryable(t *testing.T) {
t.Parallel()

// 已经 Retryable=false 的 ProviderError → 保持 false
nonRetryable := NewProviderErrorFromStatus(http.StatusUnauthorized, "bad key")
if nonRetryable.Retryable {
t.Fatal("setup: 401 should be non-retryable")
}

marked := MarkNonRetryable(nonRetryable)
var pErr *ProviderError
if !errors.As(marked, &pErr) {
t.Fatal("marked error should be *ProviderError")
}
if pErr.Retryable {
t.Fatal("should still be non-retryable")
}
}
Loading
Loading