From ebe425a86f53526acb85ae8e23acf15e679c6303 Mon Sep 17 00:00:00 2001 From: liugangjian Date: Wed, 4 Mar 2026 20:27:16 +0800 Subject: [PATCH] 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. --- go.mod | 3 +- pkg/providers/enhanced_error.go | 184 ++++++++++++++++++++++++++++++ pkg/providers/error_classifier.go | 36 ++++-- pkg/providers/fallback.go | 78 ++++++++----- pkg/providers/types.go | 36 +++++- 5 files changed, 295 insertions(+), 42 deletions(-) create mode 100644 pkg/providers/enhanced_error.go diff --git a/go.mod b/go.mod index c1172937c..6fa3a900c 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/gdamore/tcell/v2 v2.13.8 github.com/google/uuid v1.6.0 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/mdp/qrterminal/v3 v3.2.1 github.com/modelcontextprotocol/go-sdk v1.3.0 @@ -37,8 +38,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // 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/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/pkg/providers/enhanced_error.go b/pkg/providers/enhanced_error.go new file mode 100644 index 000000000..607a45e91 --- /dev/null +++ b/pkg/providers/enhanced_error.go @@ -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 +} diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index fd9bf1e81..2793da3ac 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -3,7 +3,14 @@ package providers import ( "context" "regexp" + + "crypto/rand" + "math/big" + "strings" + + "time" + ) // Common patterns in Go HTTP error messages @@ -113,14 +120,14 @@ func ClassifyError(err error, provider, model string) *FailoverError { return nil } - // Context deadline exceeded: treat as timeout, always fallback. if err == context.DeadlineExceeded { - return &FailoverError{ - Reason: FailoverTimeout, - Provider: provider, - Model: model, - Wrapped: err, - } + reqID, _ := generateRequestID() + return (&FailoverError{ + Reason: FailoverTimeout, + Provider: provider, + Model: model, + Wrapped: err, + }).SetTimestamp(time.Now()).WithRequestID(reqID) } msg := strings.ToLower(err.Error()) @@ -251,3 +258,18 @@ func parseDigits(s string) int { } 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 +} diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index 7ba563b66..99e3111e2 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -159,20 +159,33 @@ func (fc *FallbackChain) Execute( return nil, context.Canceled } - // Classify the error. - failErr := ClassifyError(err, candidate.Provider, candidate.Model) + // Classify the error after context check + failErr := ClassifyError(err, candidate.Provider, candidate.Model) + 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. + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Error: errWithReqID, + Duration: elapsed, + }) + return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w", + candidate.Provider, candidate.Model, errWithReqID) + } - if failErr == nil { - // Unclassifiable error: do not fallback, return immediately. - result.Attempts = append(result.Attempts, FallbackAttempt{ - Provider: candidate.Provider, - Model: candidate.Model, - Error: err, - Duration: elapsed, - }) - return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w", - candidate.Provider, candidate.Model, err) - } // Non-retriable error: abort immediately. if !failErr.IsRetriable() { @@ -250,22 +263,31 @@ func (fc *FallbackChain) ExecuteImage( // Image dimension/size errors are non-retriable. errMsg := strings.ToLower(err.Error()) - if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) { - result.Attempts = append(result.Attempts, FallbackAttempt{ - Provider: candidate.Provider, - Model: candidate.Model, - Error: err, - Reason: FailoverFormat, - Duration: elapsed, - }) - return nil, &FailoverError{ - Reason: FailoverFormat, - Provider: candidate.Provider, - Model: candidate.Model, - Wrapped: err, - } - } + if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) { + reqID, _ := generateRequestID() + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Error: err, + Reason: FailoverFormat, + Duration: elapsed, + }) + imageDimFailErr := &FailoverError{ + Reason: FailoverFormat, + Provider: candidate.Provider, + Model: candidate.Model, + Wrapped: err, + Timestamp: time.Now(), + } + if reqID != "" { + imageDimFailErr = imageDimFailErr.WithRequestID(reqID) + } + + return nil, imageDimFailErr + + + } // Any other error: record and try next. result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, diff --git a/pkg/providers/types.go b/pkg/providers/types.go index f0c168bc6..52b5f9626 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -3,6 +3,7 @@ package providers import ( "context" "fmt" + "time" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -52,14 +53,21 @@ const ( // FailoverError wraps an LLM provider error with classification metadata. type FailoverError struct { - Reason FailoverReason - Provider string - Model string - Status int - Wrapped error + Reason FailoverReason + Provider string + Model string + Status int + 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 { + 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", e.Reason, e.Provider, e.Model, e.Status, e.Wrapped) } @@ -74,6 +82,24 @@ func (e *FailoverError) IsRetriable() bool { 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. type ModelConfig struct { Primary string