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
95 changes: 95 additions & 0 deletions internal/provider/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package provider

import (
"fmt"
"net/http"
)

type ProviderErrorCode string

const (
ErrorCodeAuthFailed ProviderErrorCode = "auth_failed" // 认证失败(401)
ErrorCodeForbidden ProviderErrorCode = "forbidden" // 权限不足(403)
ErrorCodeNotFound ProviderErrorCode = "not_found" // 资源不存在(404)
ErrorCodeClient ProviderErrorCode = "client_error" // 客户端请求错误(4xx,排除上述分类)
ErrorCodeRateLimit ProviderErrorCode = "rate_limited" // 限流(429)
ErrorCodeServer ProviderErrorCode = "server_error" // 服务端错误(5xx)
ErrorCodeTimeout ProviderErrorCode = "timeout" // 超时
ErrorCodeNetwork ProviderErrorCode = "network_error" // 网络错误(连接拒绝、DNS 失败等)
ErrorCodeUnknown ProviderErrorCode = "unknown" // 未知错误
)

// ProviderError 是 provider 层的领域错误类型。
type ProviderError struct {
StatusCode int // HTTP 状态码,0 表示非 HTTP 错误(如网络超时)
Code ProviderErrorCode // 语义化的错误分类
Message string // 可读错误信息
Retryable bool // 是否建议重试
}

func (e *ProviderError) Error() string {
if e.StatusCode > 0 {
return fmt.Sprintf("provider error (status=%d, code=%s): %s", e.StatusCode, e.Code, e.Message)
}
return fmt.Sprintf("provider error (code=%s): %s", e.Code, e.Message)
}

// IsRetryableStatus 根据通用 HTTP 语义判断状态码是否可重试。
// 429(限流)和 5xx(服务端错误)视为可重试。
func IsRetryableStatus(statusCode int) bool {
return statusCode == http.StatusTooManyRequests ||
(statusCode >= http.StatusInternalServerError && statusCode < 600)
}

// classifyStatus 将 HTTP 状态码映射为 ProviderErrorCode。
func classifyStatus(statusCode int) ProviderErrorCode {
switch statusCode {
case http.StatusUnauthorized:
return ErrorCodeAuthFailed
case http.StatusForbidden:
return ErrorCodeForbidden
case http.StatusNotFound:
return ErrorCodeNotFound
case http.StatusTooManyRequests:
return ErrorCodeRateLimit
default:
if statusCode >= 400 && statusCode < 500 {
return ErrorCodeClient
}
if statusCode >= 500 && statusCode < 600 {
return ErrorCodeServer
}
}
return ErrorCodeUnknown
}

// NewProviderErrorFromStatus 根据 HTTP 状态码和消息构造 ProviderError。
func NewProviderErrorFromStatus(statusCode int, message string) *ProviderError {
code := classifyStatus(statusCode)
return &ProviderError{
StatusCode: statusCode,
Code: code,
Message: message,
Retryable: IsRetryableStatus(statusCode),
}
}

// NewNetworkProviderError 构造网络错误类型的 ProviderError(如连接超时、拒绝等)。
func NewNetworkProviderError(message string) *ProviderError {
return &ProviderError{
StatusCode: 0,
Code: ErrorCodeNetwork,
Message: message,
Retryable: true, // 网络抖动默认可重试
}
}

// NewTimeoutProviderError 构造超时类型的 ProviderError。
func NewTimeoutProviderError(message string) *ProviderError {
return &ProviderError{
StatusCode: 0,
Code: ErrorCodeTimeout,
Message: message,
Retryable: true, // 超时默认可重试
}
}
172 changes: 172 additions & 0 deletions internal/provider/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package provider

import (
"errors"
"fmt"
"net/http"
"strings"
"testing"
)

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

tests := []struct {
name string
err *ProviderError
want string
}{
{
name: "with status code",
err: &ProviderError{StatusCode: 401, Code: ErrorCodeAuthFailed, Message: "invalid key"},
want: "status=401",
},
{
name: "without status code",
err: &ProviderError{StatusCode: 0, Code: ErrorCodeNetwork, Message: "connection refused"},
want: "code=network_error",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.err.Error(); !strings.Contains(got, tt.want) {
t.Fatalf("Error() = %q, want containing %q", got, tt.want)
}
})
}
}

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

tests := []struct {
status int
expected bool
}{
{http.StatusBadRequest, false},
{http.StatusUnauthorized, false},
{http.StatusForbidden, false},
{http.StatusNotFound, false},
{http.StatusUnprocessableEntity, false},
{http.StatusTooManyRequests, true},
{http.StatusInternalServerError, true},
{http.StatusBadGateway, true},
{http.StatusServiceUnavailable, true},
{http.StatusGatewayTimeout, true},
{599, true},
{200, false},
{0, false},
}

for _, tt := range tests {
tt := tt
t.Run(http.StatusText(tt.status), func(t *testing.T) {
t.Parallel()
if got := IsRetryableStatus(tt.status); got != tt.expected {
t.Fatalf("IsRetryableStatus(%d) = %v, want %v", tt.status, got, tt.expected)
}
})
}
}

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

tests := []struct {
status int
want ProviderErrorCode
}{
{http.StatusUnauthorized, ErrorCodeAuthFailed},
{http.StatusForbidden, ErrorCodeForbidden},
{http.StatusNotFound, ErrorCodeNotFound},
{http.StatusBadRequest, ErrorCodeClient},
{http.StatusUnprocessableEntity, ErrorCodeClient},
{http.StatusTooManyRequests, ErrorCodeRateLimit},
{http.StatusInternalServerError, ErrorCodeServer},
{http.StatusBadGateway, ErrorCodeServer},
{http.StatusServiceUnavailable, ErrorCodeServer},
{200, ErrorCodeUnknown},
{0, ErrorCodeUnknown},
}

for _, tt := range tests {
tt := tt
t.Run(http.StatusText(tt.status), func(t *testing.T) {
t.Parallel()
if got := classifyStatus(tt.status); got != tt.want {
t.Fatalf("classifyStatus(%d) = %q, want %q", tt.status, got, tt.want)
}
})
}
}

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

err := NewProviderErrorFromStatus(429, "rate limited")
if err.StatusCode != 429 {
t.Fatalf("expected status 429, got %d", err.StatusCode)
}
if err.Code != ErrorCodeRateLimit {
t.Fatalf("expected code %q, got %q", ErrorCodeRateLimit, err.Code)
}
if !err.Retryable {
t.Fatalf("expected 429 to be retryable")
}

err = NewProviderErrorFromStatus(401, "unauthorized")
if err.Retryable {
t.Fatalf("expected 401 to not be retryable")
}
}

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

err := NewNetworkProviderError("connection refused")
if err.StatusCode != 0 {
t.Fatalf("expected status 0, got %d", err.StatusCode)
}
if err.Code != ErrorCodeNetwork {
t.Fatalf("expected code %q, got %q", ErrorCodeNetwork, err.Code)
}
if !err.Retryable {
t.Fatalf("expected network error to be retryable")
}
}

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

err := NewTimeoutProviderError("deadline exceeded")
if err.StatusCode != 0 {
t.Fatalf("expected status 0, got %d", err.StatusCode)
}
if err.Code != ErrorCodeTimeout {
t.Fatalf("expected code %q, got %q", ErrorCodeTimeout, err.Code)
}
if !err.Retryable {
t.Fatalf("expected timeout to be retryable")
}
}

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

inner := &ProviderError{StatusCode: 500, Code: ErrorCodeServer, Message: "internal", Retryable: true}
wrapped := fmt.Errorf("wrapped: %w", inner)

var target *ProviderError
if !errors.As(wrapped, &target) {
t.Fatalf("expected errors.As to match *ProviderError")
}
if target.StatusCode != 500 {
t.Fatalf("expected status 500, got %d", target.StatusCode)
}
if !target.Retryable {
t.Fatalf("expected retryable")
}
}
40 changes: 33 additions & 7 deletions internal/provider/openai/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,37 +16,61 @@ import (

"neo-code/internal/config"
domain "neo-code/internal/provider"
"neo-code/internal/provider/transport"
)

type Provider struct {
cfg config.ResolvedProviderConfig
client *http.Client
}

type buildOptions struct {
transport http.RoundTripper
}

type BuildOption func(*buildOptions)

// WithTransport 注入自定义 HTTP Transport(如 RetryTransport)。
func WithTransport(rt http.RoundTripper) BuildOption {
return func(o *buildOptions) {
o.transport = rt
}
}

const DriverName = "openai"

// Driver 返回 OpenAI 协议驱动的定义。
func Driver() domain.DriverDefinition {
return domain.DriverDefinition{
Name: DriverName,
Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (domain.Provider, error) {
return New(cfg)
baseTransport := http.DefaultTransport
retryTransport := transport.NewRetryTransport(baseTransport, transport.DefaultRetryConfig())
return New(cfg, WithTransport(retryTransport))
},
}
}

func New(cfg config.ResolvedProviderConfig) (*Provider, error) {
func New(cfg config.ResolvedProviderConfig, opts ...BuildOption) (*Provider, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("openai provider: %w", err)
}
if strings.TrimSpace(cfg.APIKey) == "" {
return nil, errors.New("openai provider: api key is empty")
}

o := &buildOptions{
transport: http.DefaultTransport,
}
for _, apply := range opts {
apply(o)
}

return &Provider{
cfg: cfg,
client: &http.Client{
Timeout: 90 * time.Second,
Timeout: 90 * time.Second,
Transport: o.transport,
},
}, nil
}
Expand Down Expand Up @@ -233,20 +257,21 @@ func (p *Provider) consumeStream(ctx context.Context, body io.Reader, events cha
func (p *Provider) parseError(resp *http.Response) error {
data, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("openai provider: read error response: %w", readErr)
return domain.NewProviderErrorFromStatus(resp.StatusCode,
fmt.Sprintf("openai provider: read error response: %v", readErr))
}

var parsed openAIErrorResponse
if err := json.Unmarshal(data, &parsed); err == nil && strings.TrimSpace(parsed.Error.Message) != "" {
return errors.New(parsed.Error.Message)
return domain.NewProviderErrorFromStatus(resp.StatusCode, parsed.Error.Message)
}

bodyText := strings.TrimSpace(string(data))
if bodyText == "" {
return errors.New(resp.Status)
return domain.NewProviderErrorFromStatus(resp.StatusCode, resp.Status)
}

return fmt.Errorf("openai provider: %s", bodyText)
return domain.NewProviderErrorFromStatus(resp.StatusCode, bodyText)
}

func toOpenAIMessage(message domain.Message) openAIMessage {
Expand Down Expand Up @@ -436,5 +461,6 @@ type openAIUsage struct {
type openAIErrorResponse struct {
Error struct {
Message string `json:"message"`
Code string `json:"code,omitempty"`
} `json:"error"`
}
Loading
Loading