feat(providers): enhance error context with request ID and timestamps

- Add new EnhancedError and ExtendedFailoverError types with detailed context
- Enhance FailoverError struct with RequestID, Correlation, and Timestamp
- Add generateRequestID() utility to create unique identifiers for tracking
- Integrate request ID generation and context into error classifier
- Update HTTP provider to attach request IDs to API call errors
- Improve error messages to include contextual information
- Maintain backward compatibility with existing error handling behavior

This change provides more detailed error information for debugging including
request tracking IDs, timestamps, and other relevant context when provider
API calls fail.
This commit is contained in:
liugangjian 2026-03-04 20:27:16 +08:00
parent 93bbe1aff3
commit ebe425a86f
5 changed files with 295 additions and 42 deletions

3
go.mod
View file

@ -11,6 +11,7 @@ require (
github.com/gdamore/tcell/v2 v2.13.8 github.com/gdamore/tcell/v2 v2.13.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1 github.com/mdp/qrterminal/v3 v3.2.1
github.com/modelcontextprotocol/go-sdk v1.3.0 github.com/modelcontextprotocol/go-sdk v1.3.0
@ -37,8 +38,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.13.8 // indirect
github.com/h2non/filetype v1.1.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect

View file

@ -0,0 +1,184 @@
package providers
import (
"fmt"
"time"
)
// RequestContext holds contextual information about an API request
type RequestContext struct {
RequestID string // Unique identifier for the request
Timestamp time.Time // Time of the request
Model string // Model name used in this call
Provider string // Provider name used in this call
UserID string // Associated user (if available)
SessionID string // Session identifier (if available)
ToolCallID string // Tool call ID if this is part of a tool call
Endpoint string // API endpoint that was called
Correlation string // Additional correlation identifier
}
// EnhancedError wraps an error with additional contextual information
type EnhancedError struct {
WrappedErr error // Original error
Context *RequestContext // Contextual information
Message string // Custom message for the enhanced error
EventType string // Type of event that caused the error (e.g. "api_request", "rate_limit", etc.)
}
func NewEnhancedError(wrappedErr error, context *RequestContext, message string, eventType string) *EnhancedError {
return &EnhancedError{
WrappedErr: wrappedErr,
Context: context,
Message: message,
EventType: eventType,
}
}
func (e *EnhancedError) Error() string {
if e.Context != nil {
return fmt.Sprintf("error: %s [event=%s, req_id=%s, provider=%s, model=%s, timestamp=%s]: %v",
e.Message, e.EventType, e.Context.RequestID, e.Context.Provider, e.Context.Model, e.Context.Timestamp.Format(time.RFC3339), e.WrappedErr)
}
return fmt.Sprintf("error: %s [event=%s]: %v", e.Message, e.EventType, e.WrappedErr)
}
func (e *EnhancedError) Unwrap() error {
return e.WrappedErr
}
// FormatDetailedError formats the error with extensive detail including all context
func (e *EnhancedError) FormatDetailedError() string {
if e.Context == nil {
return e.Error()
}
return fmt.Sprintf(`Enhanced Error Details:
Error Message: %s
Event Type: %s
Request ID: %s
Model: %s
Provider: %s
Endpoint: %s
Timestamp: %s
User ID: %s
Session ID: %s
Tool Call ID: %s
Correlation ID: %s
Wrapped Error: %v`,
e.Message,
e.EventType,
e.Context.RequestID,
e.Context.Model,
e.Context.Provider,
e.Context.Endpoint,
e.Context.Timestamp.Format(time.RFC3339),
e.Context.UserID,
e.Context.SessionID,
e.Context.ToolCallID,
e.Context.Correlation,
e.WrappedErr,
)
}
// ExtendedFailoverError enhances the original FailoverError with additional context and metadata
type ExtendedFailoverError struct {
Reason FailoverReason
Provider string
Model string
Status int
Wrapped error
RequestID string
Timestamp time.Time
Correlation string
EventType string
Metadata map[string]interface{} // Arbitrary additional metadata
Message string
}
func NewExtendedFailoverError(reason FailoverReason, provider, model string, status int, wrapped error) *ExtendedFailoverError {
return &ExtendedFailoverError{
Reason: reason,
Provider: provider,
Model: model,
Status: status,
Wrapped: wrapped,
RequestID: "", // Will be set by caller
Timestamp: time.Now(),
Correlation: "", // Will be set by caller
EventType: "provider_api_failure",
Metadata: make(map[string]interface{}),
Message: "", // Optional custom message
}
}
func (e *ExtendedFailoverError) Error() string {
baseMsg := fmt.Sprintf("extended_failover(%s): provider=%s model=%s status=%d", e.Reason, e.Provider, e.Model, e.Status)
if e.RequestID != "" {
baseMsg += fmt.Sprintf(" request_id=%s", e.RequestID)
}
if e.Correlation != "" {
baseMsg += fmt.Sprintf(" correlation=%s", e.Correlation)
}
if e.Message != "" {
baseMsg += fmt.Sprintf(" message='%s'", e.Message)
}
baseMsg += fmt.Sprintf(" timestamp=%s", e.Timestamp.Format(time.RFC3339))
// Include error details from wrapped error if present
if e.Wrapped != nil {
baseMsg += fmt.Sprintf(": %v", e.Wrapped)
}
return baseMsg
}
func (e *ExtendedFailoverError) Unwrap() error {
return e.Wrapped
}
func (e *ExtendedFailoverError) WithRequestID(id string) *ExtendedFailoverError {
e.RequestID = id
return e
}
func (e *ExtendedFailoverError) WithTimestamp(ts time.Time) *ExtendedFailoverError {
e.Timestamp = ts
return e
}
func (e *ExtendedFailoverError) WithCorrelationID(id string) *ExtendedFailoverError {
e.Correlation = id
return e
}
func (e *ExtendedFailoverError) WithEventType(eventType string) *ExtendedFailoverError {
e.EventType = eventType
return e
}
func (e *ExtendedFailoverError) WithMetadata(key string, value interface{}) *ExtendedFailoverError {
if e.Metadata == nil {
e.Metadata = make(map[string]interface{})
}
e.Metadata[key] = value
return e
}
func (e *ExtendedFailoverError) WithMessage(message string) *ExtendedFailoverError {
e.Message = message
return e
}
// IsRetriable returns true if this error should trigger fallback to next candidate.
// Non-retriable: Format errors (bad request structure, image dimension/size).
func (e *ExtendedFailoverError) IsRetriable() bool {
return e.Reason != FailoverFormat
}
// Convert a regular FailoverError to an ExtendedFailoverError
func (e *FailoverError) AsExtendedError() *ExtendedFailoverError {
extended := NewExtendedFailoverError(e.Reason, e.Provider, e.Model, e.Status, e.Wrapped)
extended.Timestamp = time.Now()
return extended
}

View file

@ -3,7 +3,14 @@ package providers
import ( import (
"context" "context"
"regexp" "regexp"
"crypto/rand"
"math/big"
"strings" "strings"
"time"
) )
// Common patterns in Go HTTP error messages // Common patterns in Go HTTP error messages
@ -113,14 +120,14 @@ func ClassifyError(err error, provider, model string) *FailoverError {
return nil return nil
} }
// Context deadline exceeded: treat as timeout, always fallback.
if err == context.DeadlineExceeded { if err == context.DeadlineExceeded {
return &FailoverError{ reqID, _ := generateRequestID()
return (&FailoverError{
Reason: FailoverTimeout, Reason: FailoverTimeout,
Provider: provider, Provider: provider,
Model: model, Model: model,
Wrapped: err, Wrapped: err,
} }).SetTimestamp(time.Now()).WithRequestID(reqID)
} }
msg := strings.ToLower(err.Error()) msg := strings.ToLower(err.Error())
@ -251,3 +258,18 @@ func parseDigits(s string) int {
} }
return n return n
} }
// generateRequestID creates a unique request ID for tracking API calls
func generateRequestID() (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const length = 16
b := make([]byte, length)
for i := range b {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return "", err
}
b[i] = charset[n.Int64()]
}
return "req_" + string(b), nil
}

View file

@ -159,21 +159,34 @@ func (fc *FallbackChain) Execute(
return nil, context.Canceled return nil, context.Canceled
} }
// Classify the error. // Classify the error after context check
failErr := ClassifyError(err, candidate.Provider, candidate.Model) failErr := ClassifyError(err, candidate.Provider, candidate.Model)
if failErr == nil { if failErr == nil {
// Generate request ID for tracking
reqID, genErr := generateRequestID()
errWithReqID := &FailoverError{
Reason: FailoverUnknown, // Will treat as unclassifiable as before
Provider: candidate.Provider,
Model: candidate.Model,
Wrapped: err,
Timestamp: time.Now(),
}
if genErr == nil && reqID != "" {
errWithReqID = errWithReqID.WithRequestID(reqID)
}
// Unclassifiable error: do not fallback, return immediately. // Unclassifiable error: do not fallback, return immediately.
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider, Provider: candidate.Provider,
Model: candidate.Model, Model: candidate.Model,
Error: err, Error: errWithReqID,
Duration: elapsed, Duration: elapsed,
}) })
return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w", return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w",
candidate.Provider, candidate.Model, err) candidate.Provider, candidate.Model, errWithReqID)
} }
// Non-retriable error: abort immediately. // Non-retriable error: abort immediately.
if !failErr.IsRetriable() { if !failErr.IsRetriable() {
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
@ -251,6 +264,8 @@ func (fc *FallbackChain) ExecuteImage(
// Image dimension/size errors are non-retriable. // Image dimension/size errors are non-retriable.
errMsg := strings.ToLower(err.Error()) errMsg := strings.ToLower(err.Error())
if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) { if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) {
reqID, _ := generateRequestID()
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider, Provider: candidate.Provider,
Model: candidate.Model, Model: candidate.Model,
@ -258,14 +273,21 @@ func (fc *FallbackChain) ExecuteImage(
Reason: FailoverFormat, Reason: FailoverFormat,
Duration: elapsed, Duration: elapsed,
}) })
return nil, &FailoverError{ imageDimFailErr := &FailoverError{
Reason: FailoverFormat, Reason: FailoverFormat,
Provider: candidate.Provider, Provider: candidate.Provider,
Model: candidate.Model, Model: candidate.Model,
Wrapped: err, Wrapped: err,
Timestamp: time.Now(),
} }
if reqID != "" {
imageDimFailErr = imageDimFailErr.WithRequestID(reqID)
} }
return nil, imageDimFailErr
}
// Any other error: record and try next. // Any other error: record and try next.
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider, Provider: candidate.Provider,

View file

@ -3,6 +3,7 @@ package providers
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
@ -57,9 +58,16 @@ type FailoverError struct {
Model string Model string
Status int Status int
Wrapped error Wrapped error
Timestamp time.Time // Timestamp of when the error occurred
RequestID string // Request ID for tracking
Correlation string // Correlation ID
} }
func (e *FailoverError) Error() string { func (e *FailoverError) Error() string {
if e.RequestID != "" {
return fmt.Sprintf("failover(%s): provider=%s model=%s status=%d req_id=%s correlation=%s timestamp=%s: %v",
e.Reason, e.Provider, e.Model, e.Status, e.RequestID, e.Correlation, e.Timestamp.Format(time.RFC3339), e.Wrapped)
}
return fmt.Sprintf("failover(%s): provider=%s model=%s status=%d: %v", return fmt.Sprintf("failover(%s): provider=%s model=%s status=%d: %v",
e.Reason, e.Provider, e.Model, e.Status, e.Wrapped) e.Reason, e.Provider, e.Model, e.Status, e.Wrapped)
} }
@ -74,6 +82,24 @@ func (e *FailoverError) IsRetriable() bool {
return e.Reason != FailoverFormat return e.Reason != FailoverFormat
} }
// SetTimestamp stores when the error occurred
func (e *FailoverError) SetTimestamp(t time.Time) *FailoverError {
e.Timestamp = t
return e
}
// WithRequestID sets the request ID for this error
func (e *FailoverError) WithRequestID(id string) *FailoverError {
e.RequestID = id
return e
}
// WithCorrelation sets the correlation identifier for this error
func (e *FailoverError) WithCorrelation(correlation string) *FailoverError {
e.Correlation = correlation
return e
}
// ModelConfig holds primary model and fallback list. // ModelConfig holds primary model and fallback list.
type ModelConfig struct { type ModelConfig struct {
Primary string Primary string