From 504fc265fc9bd8883661022a61bb2ea0d22f4b6d Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 4 Nov 2025 16:36:59 -0300 Subject: [PATCH 1/9] refactor: remove unused error types --- errors.go | 155 ------------------------------------------------------ 1 file changed, 155 deletions(-) diff --git a/errors.go b/errors.go index c096ff2fdae84fdafab4546b26b6ffbec0040337..def84eea12b813096d5c3243109118d1dc6a9182 100644 --- a/errors.go +++ b/errors.go @@ -71,21 +71,6 @@ func NewAPICallError(message, url string, requestDump string, statusCode int, re } } -// EmptyResponseBodyError represents an empty response body error. -type EmptyResponseBodyError struct { - *AIError -} - -// NewEmptyResponseBodyError creates a new empty response body error. -func NewEmptyResponseBodyError(message string) *EmptyResponseBodyError { - if message == "" { - message = "Empty response body" - } - return &EmptyResponseBodyError{ - AIError: NewAIError("AI_EmptyResponseBodyError", message, nil), - } -} - // InvalidArgumentError represents an invalid function argument error. type InvalidArgumentError struct { *AIError @@ -132,146 +117,6 @@ func NewInvalidResponseDataError(data any, message string) *InvalidResponseDataE } } -// JSONParseError represents a JSON parsing error. -type JSONParseError struct { - *AIError - Text string -} - -// NewJSONParseError creates a new JSON parse error. -func NewJSONParseError(text string, cause error) *JSONParseError { - message := fmt.Sprintf("JSON parsing failed: Text: %s.\nError message: %s", text, GetErrorMessage(cause)) - return &JSONParseError{ - AIError: NewAIError("AI_JSONParseError", message, cause), - Text: text, - } -} - -// LoadAPIKeyError represents an error loading an API key. -type LoadAPIKeyError struct { - *AIError -} - -// NewLoadAPIKeyError creates a new load API key error. -func NewLoadAPIKeyError(message string) *LoadAPIKeyError { - return &LoadAPIKeyError{ - AIError: NewAIError("AI_LoadAPIKeyError", message, nil), - } -} - -// LoadSettingError represents an error loading a setting. -type LoadSettingError struct { - *AIError -} - -// NewLoadSettingError creates a new load setting error. -func NewLoadSettingError(message string) *LoadSettingError { - return &LoadSettingError{ - AIError: NewAIError("AI_LoadSettingError", message, nil), - } -} - -// NoContentGeneratedError is thrown when the AI provider fails to generate any content. -type NoContentGeneratedError struct { - *AIError -} - -// NewNoContentGeneratedError creates a new no content generated error. -func NewNoContentGeneratedError(message string) *NoContentGeneratedError { - if message == "" { - message = "No content generated." - } - return &NoContentGeneratedError{ - AIError: NewAIError("AI_NoContentGeneratedError", message, nil), - } -} - -// ModelType represents the type of model. -type ModelType string - -const ( - // ModelTypeLanguage represents a language model. - ModelTypeLanguage ModelType = "languageModel" - // ModelTypeTextEmbedding represents a text embedding model. - ModelTypeTextEmbedding ModelType = "textEmbeddingModel" - // ModelTypeImage represents an image model. - ModelTypeImage ModelType = "imageModel" - // ModelTypeTranscription represents a transcription model. - ModelTypeTranscription ModelType = "transcriptionModel" - // ModelTypeSpeech represents a speech model. - ModelTypeSpeech ModelType = "speechModel" -) - -// NoSuchModelError represents an error when a model is not found. -type NoSuchModelError struct { - *AIError - ModelID string - ModelType ModelType -} - -// NewNoSuchModelError creates a new no such model error. -func NewNoSuchModelError(modelID string, modelType ModelType, message string) *NoSuchModelError { - if message == "" { - message = fmt.Sprintf("No such %s: %s", modelType, modelID) - } - return &NoSuchModelError{ - AIError: NewAIError("AI_NoSuchModelError", message, nil), - ModelID: modelID, - ModelType: modelType, - } -} - -// TooManyEmbeddingValuesForCallError represents an error when too many values are provided for embedding. -type TooManyEmbeddingValuesForCallError struct { - *AIError - Provider string - ModelID string - MaxEmbeddingsPerCall int - Values []any -} - -// NewTooManyEmbeddingValuesForCallError creates a new too many embedding values error. -func NewTooManyEmbeddingValuesForCallError(provider, modelID string, maxEmbeddingsPerCall int, values []any) *TooManyEmbeddingValuesForCallError { - message := fmt.Sprintf( - "Too many values for a single embedding call. The %s model \"%s\" can only embed up to %d values per call, but %d values were provided.", - provider, modelID, maxEmbeddingsPerCall, len(values), - ) - return &TooManyEmbeddingValuesForCallError{ - AIError: NewAIError("AI_TooManyEmbeddingValuesForCallError", message, nil), - Provider: provider, - ModelID: modelID, - MaxEmbeddingsPerCall: maxEmbeddingsPerCall, - Values: values, - } -} - -// TypeValidationError represents a type validation error. -type TypeValidationError struct { - *AIError - Value any -} - -// NewTypeValidationError creates a new type validation error. -func NewTypeValidationError(value any, cause error) *TypeValidationError { - valueJSON, _ := json.Marshal(value) - message := fmt.Sprintf( - "Type validation failed: Value: %s.\nError message: %s", - string(valueJSON), GetErrorMessage(cause), - ) - return &TypeValidationError{ - AIError: NewAIError("AI_TypeValidationError", message, cause), - Value: value, - } -} - -// WrapTypeValidationError wraps an error into a TypeValidationError. -func WrapTypeValidationError(value any, cause error) *TypeValidationError { - if tvErr, ok := cause.(*TypeValidationError); ok && tvErr.Value == value { - return tvErr - } - return NewTypeValidationError(value, cause) -} - // UnsupportedFunctionalityError represents an unsupported functionality error. type UnsupportedFunctionalityError struct { *AIError From 34c5c98d1b952d9311980af2f07be1b818118139 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 4 Nov 2025 16:58:25 -0300 Subject: [PATCH 2/9] chore: remove `GetErrorMessage` helper --- errors.go | 8 -------- retry.go | 6 +++--- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/errors.go b/errors.go index def84eea12b813096d5c3243109118d1dc6a9182..5a86b256d27ba64dd37144a72e2cd939ef1d2ca2 100644 --- a/errors.go +++ b/errors.go @@ -133,11 +133,3 @@ func NewUnsupportedFunctionalityError(functionality, message string) *Unsupporte Functionality: functionality, } } - -// GetErrorMessage extracts a message from an error. -func GetErrorMessage(err error) string { - if err == nil { - return "unknown error" - } - return err.Error() -} diff --git a/retry.go b/retry.go index 23581fd2286ea7e676b51316e42a7514bfd26789..0908bb7a5535ed435ad74c18a8fc241791e28450 100644 --- a/retry.go +++ b/retry.go @@ -129,13 +129,13 @@ func retryWithExponentialBackoff[T any](ctx context.Context, fn RetryFn[T], opti return zero, err // don't wrap the error when retries are disabled } - errorMessage := GetErrorMessage(err) + errorMessage := err.Error() newErrors := append(allErrors, err) tryNumber := len(newErrors) if tryNumber > options.MaxRetries { return zero, NewRetryError( - fmt.Sprintf("Failed after %d attempts. Last error: %s", tryNumber, errorMessage), + fmt.Sprintf("Failed after %d attempts. Last error: %v", tryNumber, errorMessage), RetryReasonMaxRetriesExceeded, newErrors, ) @@ -166,7 +166,7 @@ func retryWithExponentialBackoff[T any](ctx context.Context, fn RetryFn[T], opti } return zero, NewRetryError( - fmt.Sprintf("Failed after %d attempts with non-retryable error: '%s'", tryNumber, errorMessage), + fmt.Sprintf("Failed after %d attempts with non-retryable error: %v", tryNumber, errorMessage), RetryReasonErrorNotRetryable, newErrors, ) From 512e9deb341a1ff6d7b72a8e0542505e78c04db5 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 4 Nov 2025 17:02:39 -0300 Subject: [PATCH 3/9] refactor(errors): remove unused `Name` attribute --- errors.go | 14 ++++++-------- providers/openaicompat/language_model_hooks.go | 2 +- providers/openrouter/language_model_hooks.go | 2 +- retry.go | 2 +- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/errors.go b/errors.go index 5a86b256d27ba64dd37144a72e2cd939ef1d2ca2..1c77c1f709bb2d9eae94074006568e3c38759305 100644 --- a/errors.go +++ b/errors.go @@ -11,7 +11,6 @@ var markerSymbol = "fantasy.error" // AIError is a custom error type for AI SDK related errors. type AIError struct { - Name string Message string Cause error marker string @@ -28,9 +27,8 @@ func (e *AIError) Unwrap() error { } // NewAIError creates a new AI SDK Error. -func NewAIError(name, message string, cause error) *AIError { +func NewAIError(message string, cause error) *AIError { return &AIError{ - Name: name, Message: message, Cause: cause, marker: markerSymbol, @@ -61,7 +59,7 @@ func NewAPICallError(message, url string, requestDump string, statusCode int, re } return &APICallError{ - AIError: NewAIError("AI_APICallError", message, cause), + AIError: NewAIError(message, cause), URL: url, RequestDump: requestDump, StatusCode: statusCode, @@ -80,7 +78,7 @@ type InvalidArgumentError struct { // NewInvalidArgumentError creates a new invalid argument error. func NewInvalidArgumentError(argument, message string, cause error) *InvalidArgumentError { return &InvalidArgumentError{ - AIError: NewAIError("AI_InvalidArgumentError", message, cause), + AIError: NewAIError(message, cause), Argument: argument, } } @@ -94,7 +92,7 @@ type InvalidPromptError struct { // NewInvalidPromptError creates a new invalid prompt error. func NewInvalidPromptError(prompt any, message string, cause error) *InvalidPromptError { return &InvalidPromptError{ - AIError: NewAIError("AI_InvalidPromptError", fmt.Sprintf("Invalid prompt: %s", message), cause), + AIError: NewAIError(fmt.Sprintf("Invalid prompt: %s", message), cause), Prompt: prompt, } } @@ -112,7 +110,7 @@ func NewInvalidResponseDataError(data any, message string) *InvalidResponseDataE message = fmt.Sprintf("Invalid response data: %s.", string(dataJSON)) } return &InvalidResponseDataError{ - AIError: NewAIError("AI_InvalidResponseDataError", message, nil), + AIError: NewAIError(message, nil), Data: data, } } @@ -129,7 +127,7 @@ func NewUnsupportedFunctionalityError(functionality, message string) *Unsupporte message = fmt.Sprintf("'%s' functionality not supported.", functionality) } return &UnsupportedFunctionalityError{ - AIError: NewAIError("AI_UnsupportedFunctionalityError", message, nil), + AIError: NewAIError(message, nil), Functionality: functionality, } } diff --git a/providers/openaicompat/language_model_hooks.go b/providers/openaicompat/language_model_hooks.go index 5fa06818531331feb18b90d88f1c1948b1b961e8..f9f16b33f7cc97d46d059b444b9edf07d5ed28e0 100644 --- a/providers/openaicompat/language_model_hooks.go +++ b/providers/openaicompat/language_model_hooks.go @@ -86,7 +86,7 @@ func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.Str if err != nil { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, - Error: fantasy.NewAIError("Unexpected", "error unmarshalling delta", err), + Error: fantasy.NewAIError("error unmarshalling delta", err), }) return ctx, false } diff --git a/providers/openrouter/language_model_hooks.go b/providers/openrouter/language_model_hooks.go index 60488360bd2a7d07175c2a2a073df4171ebf850f..59a1935be43a96e297c9f590b6f844bba2388632 100644 --- a/providers/openrouter/language_model_hooks.go +++ b/providers/openrouter/language_model_hooks.go @@ -180,7 +180,7 @@ func languageModelStreamExtra(chunk openaisdk.ChatCompletionChunk, yield func(fa if err != nil { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, - Error: fantasy.NewAIError("Unexpected", "error unmarshalling delta", err), + Error: fantasy.NewAIError("error unmarshalling delta", err), }) return ctx, false } diff --git a/retry.go b/retry.go index 0908bb7a5535ed435ad74c18a8fc241791e28450..4ef40d71361953837c718434559dc1a0df94c91e 100644 --- a/retry.go +++ b/retry.go @@ -34,7 +34,7 @@ type RetryError struct { // NewRetryError creates a new retry error. func NewRetryError(message string, reason RetryReason, errors []error) *RetryError { return &RetryError{ - AIError: NewAIError("AI_RetryError", message, nil), + AIError: NewAIError(message, nil), Reason: reason, Errors: errors, } From 23cf40dd6d730479e78f397ffc30f9433496d259 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 4 Nov 2025 17:22:29 -0300 Subject: [PATCH 4/9] refactor(errors): remove uneeded marker attribute --- errors.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/errors.go b/errors.go index 1c77c1f709bb2d9eae94074006568e3c38759305..ffa07aa6ee9cd7adcc5601f5fe016564fbc2ecc7 100644 --- a/errors.go +++ b/errors.go @@ -6,14 +6,10 @@ import ( "fmt" ) -// markerSymbol is used for identifying AI SDK Error instances. -var markerSymbol = "fantasy.error" - // AIError is a custom error type for AI SDK related errors. type AIError struct { Message string Cause error - marker string } // Error implements the error interface. @@ -31,14 +27,13 @@ func NewAIError(message string, cause error) *AIError { return &AIError{ Message: message, Cause: cause, - marker: markerSymbol, } } // IsAIError checks if the given error is an AI SDK Error. func IsAIError(err error) bool { var sdkErr *AIError - return errors.As(err, &sdkErr) && sdkErr.marker == markerSymbol + return errors.As(err, &sdkErr) } // APICallError represents an error from an API call. From 789f18b460252a5c6e6998edd2502be9e5c95718 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 5 Nov 2025 14:40:08 -0300 Subject: [PATCH 5/9] refactor: rework error types --- agent.go | 2 +- agent_test.go | 2 +- errors.go | 134 +++++------------- providers/anthropic/anthropic.go | 24 ++-- providers/google/google.go | 2 +- providers/openai/language_model.go | 26 ++-- providers/openai/language_model_hooks.go | 2 +- providers/openai/responses_language_model.go | 20 +-- .../openaicompat/language_model_hooks.go | 4 +- providers/openrouter/language_model_hooks.go | 4 +- retry.go | 54 ++----- 11 files changed, 90 insertions(+), 184 deletions(-) diff --git a/agent.go b/agent.go index 214cb388918fa15115716ccf58d96fffce36cf41..52015b5fd90ca988cb8e7af763e38693bfd4c9e1 100644 --- a/agent.go +++ b/agent.go @@ -971,7 +971,7 @@ func (a *agent) validateToolCall(toolCall ToolCallContent, availableTools []Agen func (a *agent) createPrompt(system, prompt string, messages []Message, files ...FilePart) (Prompt, error) { if prompt == "" { - return nil, NewInvalidPromptError(prompt, "Prompt can't be empty", nil) + return nil, &Error{Title: "invalid argument", Message: "prompt can't be empty"} } var preparedPrompt Prompt diff --git a/agent_test.go b/agent_test.go index 2929e3c0c98100231f498a8084dc46247d57cb1f..dce488116c012e2be15034750cf87779a70671f7 100644 --- a/agent_test.go +++ b/agent_test.go @@ -528,7 +528,7 @@ func TestAgent_Generate_EmptyPrompt(t *testing.T) { require.Error(t, err) require.Nil(t, result) - require.Contains(t, err.Error(), "Prompt can't be empty") + require.Contains(t, err.Error(), "invalid argument: prompt can't be empty") } // Test with system prompt diff --git a/errors.go b/errors.go index ffa07aa6ee9cd7adcc5601f5fe016564fbc2ecc7..df7998e3ab717b2f287d2fb53a822d459c1c3dd2 100644 --- a/errors.go +++ b/errors.go @@ -1,128 +1,70 @@ package fantasy import ( - "encoding/json" - "errors" "fmt" + "net/http" + + "github.com/charmbracelet/x/exp/slice" ) -// AIError is a custom error type for AI SDK related errors. -type AIError struct { +// Error is a custom error type for the fantasy package. +type Error struct { Message string + Title string Cause error } -// Error implements the error interface. -func (e *AIError) Error() string { - return e.Message -} - -// Unwrap returns the underlying cause of the error. -func (e *AIError) Unwrap() error { - return e.Cause -} - -// NewAIError creates a new AI SDK Error. -func NewAIError(message string, cause error) *AIError { - return &AIError{ - Message: message, - Cause: cause, +func (err *Error) Error() string { + if err.Title == "" { + return err.Message } + return fmt.Sprintf("%s: %s", err.Title, err.Message) } -// IsAIError checks if the given error is an AI SDK Error. -func IsAIError(err error) bool { - var sdkErr *AIError - return errors.As(err, &sdkErr) +func (err Error) Unwrap() error { + return err.Cause } -// APICallError represents an error from an API call. -type APICallError struct { - *AIError +// ProviderError represents an error returned by an external provider. +type ProviderError struct { + Message string + Title string + Cause error + URL string - RequestDump string StatusCode int + RequestBody []byte ResponseHeaders map[string]string - ResponseDump string - IsRetryable bool -} - -// NewAPICallError creates a new API call error. -func NewAPICallError(message, url string, requestDump string, statusCode int, responseHeaders map[string]string, responseDump string, cause error, isRetryable bool) *APICallError { - if !isRetryable && statusCode != 0 { - isRetryable = statusCode == 408 || statusCode == 409 || statusCode == 429 || statusCode >= 500 - } - - return &APICallError{ - AIError: NewAIError(message, cause), - URL: url, - RequestDump: requestDump, - StatusCode: statusCode, - ResponseHeaders: responseHeaders, - ResponseDump: responseDump, - IsRetryable: isRetryable, - } -} - -// InvalidArgumentError represents an invalid function argument error. -type InvalidArgumentError struct { - *AIError - Argument string + ResponseBody []byte } -// NewInvalidArgumentError creates a new invalid argument error. -func NewInvalidArgumentError(argument, message string, cause error) *InvalidArgumentError { - return &InvalidArgumentError{ - AIError: NewAIError(message, cause), - Argument: argument, +func (m *ProviderError) Error() string { + if m.Title == "" { + return m.Message } + return fmt.Sprintf("%s: %s", m.Title, m.Message) } -// InvalidPromptError represents an invalid prompt error. -type InvalidPromptError struct { - *AIError - Prompt any +// IsRetryable checks if the error is retryable based on the status code. +func (m *ProviderError) IsRetryable() bool { + return m.StatusCode == http.StatusRequestTimeout || m.StatusCode == http.StatusConflict || m.StatusCode == http.StatusTooManyRequests } -// NewInvalidPromptError creates a new invalid prompt error. -func NewInvalidPromptError(prompt any, message string, cause error) *InvalidPromptError { - return &InvalidPromptError{ - AIError: NewAIError(fmt.Sprintf("Invalid prompt: %s", message), cause), - Prompt: prompt, - } +// RetryError represents an error that occurred during retry operations. +type RetryError struct { + Errors []error } -// InvalidResponseDataError represents invalid response data from the server. -type InvalidResponseDataError struct { - *AIError - Data any -} - -// NewInvalidResponseDataError creates a new invalid response data error. -func NewInvalidResponseDataError(data any, message string) *InvalidResponseDataError { - if message == "" { - dataJSON, _ := json.Marshal(data) - message = fmt.Sprintf("Invalid response data: %s.", string(dataJSON)) - } - return &InvalidResponseDataError{ - AIError: NewAIError(message, nil), - Data: data, +func (e *RetryError) Error() string { + if err, ok := slice.Last(e.Errors); ok { + return fmt.Sprintf("retry error: %v", err) } + return "retry error: no underlying errors" } -// UnsupportedFunctionalityError represents an unsupported functionality error. -type UnsupportedFunctionalityError struct { - *AIError - Functionality string -} - -// NewUnsupportedFunctionalityError creates a new unsupported functionality error. -func NewUnsupportedFunctionalityError(functionality, message string) *UnsupportedFunctionalityError { - if message == "" { - message = fmt.Sprintf("'%s' functionality not supported.", functionality) - } - return &UnsupportedFunctionalityError{ - AIError: NewAIError(message, nil), - Functionality: functionality, +func (e RetryError) Unwrap() error { + if err, ok := slice.Last(e.Errors); ok { + return err } + return nil } diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 42f8d72f1039cabf9c85341120655b4a24125fa3..9b0fb97e3d7617685a31445cd9e1eab539348360 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -202,7 +202,7 @@ func (a languageModel) prepareParams(call fantasy.Call) (*anthropic.MessageNewPa if v, ok := call.ProviderOptions[Name]; ok { providerOptions, ok = v.(*ProviderOptions) if !ok { - return nil, nil, fantasy.NewInvalidArgumentError("providerOptions", "anthropic provider options should be *anthropic.ProviderOptions", nil) + return nil, nil, &fantasy.Error{Title: "invalid argument", Message: "anthropic provider options should be *anthropic.ProviderOptions"} } } sendReasoning := true @@ -251,7 +251,7 @@ func (a languageModel) prepareParams(call fantasy.Call) (*anthropic.MessageNewPa } if isThinking { if thinkingBudget == 0 { - return nil, nil, fantasy.NewUnsupportedFunctionalityError("thinking requires budget", "") + return nil, nil, &fantasy.Error{Title: "no budget", Message: "thinking requires budget"} } params.Thinking = anthropic.ThinkingConfigParamOfEnabled(thinkingBudget) if call.Temperature != nil { @@ -700,16 +700,16 @@ func (a languageModel) handleError(err error) error { v := h[len(h)-1] headers[strings.ToLower(k)] = v } - return fantasy.NewAPICallError( - apiErr.Error(), - apiErr.Request.URL.String(), - string(requestDump), - apiErr.StatusCode, - headers, - string(responseDump), - apiErr, - false, - ) + return &fantasy.ProviderError{ + Title: "provider request failed", + Message: apiErr.Error(), + Cause: apiErr, + URL: apiErr.Request.URL.String(), + StatusCode: apiErr.StatusCode, + RequestBody: requestDump, + ResponseHeaders: headers, + ResponseBody: responseDump, + } } return err } diff --git a/providers/google/google.go b/providers/google/google.go index c59ffb3c8d2542bc546b98f592572bab5a8063fa..0434c9ea01db63452a6704cad1fe61093f56b4f9 100644 --- a/providers/google/google.go +++ b/providers/google/google.go @@ -197,7 +197,7 @@ func (g languageModel) prepareParams(call fantasy.Call) (*genai.GenerateContentC if v, ok := call.ProviderOptions[Name]; ok { providerOptions, ok = v.(*ProviderOptions) if !ok { - return nil, nil, nil, fantasy.NewInvalidArgumentError("providerOptions", "google provider options should be *google.ProviderOptions", nil) + return nil, nil, nil, &fantasy.Error{Title: "invalid argument", Message: "google provider options should be *google.ProviderOptions"} } } diff --git a/providers/openai/language_model.go b/providers/openai/language_model.go index 72d22808a27fce5b8c1688b94b248eb86bfdd114..d0b8212f6e00b2cb1e94d3dc23d42e00ef2b6ede 100644 --- a/providers/openai/language_model.go +++ b/providers/openai/language_model.go @@ -233,16 +233,16 @@ func (o languageModel) handleError(err error) error { v := h[len(h)-1] headers[strings.ToLower(k)] = v } - return fantasy.NewAPICallError( - apiErr.Message, - apiErr.Request.URL.String(), - string(requestDump), - apiErr.StatusCode, - headers, - string(responseDump), - apiErr, - false, - ) + return &fantasy.ProviderError{ + Title: "provider request failed", + Message: apiErr.Message, + Cause: apiErr, + URL: apiErr.Request.URL.String(), + StatusCode: apiErr.StatusCode, + RequestBody: requestDump, + ResponseHeaders: headers, + ResponseBody: responseDump, + } } return err } @@ -422,13 +422,13 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S // Does not exist var err error if toolCallDelta.Type != "function" { - err = fantasy.NewInvalidResponseDataError(toolCallDelta, "Expected 'function' type.") + err = &fantasy.Error{Title: "invalid provider response", Message: "expected 'function' type."} } if toolCallDelta.ID == "" { - err = fantasy.NewInvalidResponseDataError(toolCallDelta, "Expected 'id' to be a string.") + err = &fantasy.Error{Title: "invalid provider response", Message: "expected 'id' to be a string."} } if toolCallDelta.Function.Name == "" { - err = fantasy.NewInvalidResponseDataError(toolCallDelta, "Expected 'function.name' to be a string.") + err = &fantasy.Error{Title: "invalid provider response", Message: "expected 'function.name' to be a string."} } if err != nil { yield(fantasy.StreamPart{ diff --git a/providers/openai/language_model_hooks.go b/providers/openai/language_model_hooks.go index 576dcf46ebc8375ec0a74547ff7b5969c9fd692f..9ca2c64f520a1dbf01f95f46ac097689b2e6d045 100644 --- a/providers/openai/language_model_hooks.go +++ b/providers/openai/language_model_hooks.go @@ -45,7 +45,7 @@ func DefaultPrepareCallFunc(model fantasy.LanguageModel, params *openai.ChatComp if v, ok := call.ProviderOptions[Name]; ok { providerOptions, ok = v.(*ProviderOptions) if !ok { - return nil, fantasy.NewInvalidArgumentError("providerOptions", "openai provider options should be *openai.ProviderOptions", nil) + return nil, &fantasy.Error{Title: "invalid argument", Message: "openai provider options should be *openai.ProviderOptions"} } } diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index faa2f07c2c3b0b0862ad4da2135cdb7c19a126d0..2f69551011d14df85b29a0f89ad7ae06a10eec48 100644 --- a/providers/openai/responses_language_model.go +++ b/providers/openai/responses_language_model.go @@ -659,16 +659,16 @@ func (o responsesLanguageModel) handleError(err error) error { v := h[len(h)-1] headers[strings.ToLower(k)] = v } - return fantasy.NewAPICallError( - apiErr.Message, - apiErr.Request.URL.String(), - string(requestDump), - apiErr.StatusCode, - headers, - string(responseDump), - apiErr, - false, - ) + return &fantasy.ProviderError{ + Title: "provider request failed", + Message: apiErr.Message, + Cause: apiErr, + URL: apiErr.Request.URL.String(), + StatusCode: apiErr.StatusCode, + RequestBody: requestDump, + ResponseHeaders: headers, + ResponseBody: responseDump, + } } return err } diff --git a/providers/openaicompat/language_model_hooks.go b/providers/openaicompat/language_model_hooks.go index f9f16b33f7cc97d46d059b444b9edf07d5ed28e0..d3c5a8c6b5cdbc67c58825399754325131e323fe 100644 --- a/providers/openaicompat/language_model_hooks.go +++ b/providers/openaicompat/language_model_hooks.go @@ -19,7 +19,7 @@ func PrepareCallFunc(_ fantasy.LanguageModel, params *openaisdk.ChatCompletionNe if v, ok := call.ProviderOptions[Name]; ok { providerOptions, ok = v.(*ProviderOptions) if !ok { - return nil, fantasy.NewInvalidArgumentError("providerOptions", "openrouter provider options should be *openrouter.ProviderOptions", nil) + return nil, &fantasy.Error{Title: "invalid argument", Message: "openrouter provider options should be *openrouter.ProviderOptions"} } } @@ -86,7 +86,7 @@ func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.Str if err != nil { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, - Error: fantasy.NewAIError("error unmarshalling delta", err), + Error: &fantasy.Error{Title: "stream error", Message: "error unmarshalling delta", Cause: err}, }) return ctx, false } diff --git a/providers/openrouter/language_model_hooks.go b/providers/openrouter/language_model_hooks.go index 59a1935be43a96e297c9f590b6f844bba2388632..77cc839414d2aeb2b856a92a5b1b22d5b9946d66 100644 --- a/providers/openrouter/language_model_hooks.go +++ b/providers/openrouter/language_model_hooks.go @@ -21,7 +21,7 @@ func languagePrepareModelCall(_ fantasy.LanguageModel, params *openaisdk.ChatCom if v, ok := call.ProviderOptions[Name]; ok { providerOptions, ok = v.(*ProviderOptions) if !ok { - return nil, fantasy.NewInvalidArgumentError("providerOptions", "openrouter provider options should be *openrouter.ProviderOptions", nil) + return nil, &fantasy.Error{Title: "invalid argument", Message: "openrouter provider options should be *openrouter.ProviderOptions"} } } @@ -180,7 +180,7 @@ func languageModelStreamExtra(chunk openaisdk.ChatCompletionChunk, yield func(fa if err != nil { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, - Error: fantasy.NewAIError("error unmarshalling delta", err), + Error: &fantasy.Error{Title: "stream error", Message: "error unmarshalling delta", Cause: err}, }) return ctx, false } diff --git a/retry.go b/retry.go index 4ef40d71361953837c718434559dc1a0df94c91e..6b2c2a412f728b589099f0fe8e41f4e1e93f78a8 100644 --- a/retry.go +++ b/retry.go @@ -3,7 +3,6 @@ package fantasy import ( "context" "errors" - "fmt" "strconv" "time" ) @@ -14,40 +13,14 @@ type RetryFn[T any] func() (T, error) // RetryFunction is a function that retries another function. type RetryFunction[T any] func(ctx context.Context, fn RetryFn[T]) (T, error) -// RetryReason represents the reason why a retry operation failed. -type RetryReason string - -const ( - // RetryReasonMaxRetriesExceeded indicates the maximum number of retries was exceeded. - RetryReasonMaxRetriesExceeded RetryReason = "maxRetriesExceeded" - // RetryReasonErrorNotRetryable indicates the error is not retryable. - RetryReasonErrorNotRetryable RetryReason = "errorNotRetryable" -) - -// RetryError represents an error that occurred during retry operations. -type RetryError struct { - *AIError - Reason RetryReason - Errors []error -} - -// NewRetryError creates a new retry error. -func NewRetryError(message string, reason RetryReason, errors []error) *RetryError { - return &RetryError{ - AIError: NewAIError(message, nil), - Reason: reason, - Errors: errors, - } -} - // getRetryDelayInMs calculates the retry delay based on error headers and exponential backoff. func getRetryDelayInMs(err error, exponentialBackoffDelay time.Duration) time.Duration { - var apiErr *APICallError - if !errors.As(err, &apiErr) || apiErr.ResponseHeaders == nil { + var providerErr *ProviderError + if !errors.As(err, &providerErr) || providerErr.ResponseHeaders == nil { return exponentialBackoffDelay } - headers := apiErr.ResponseHeaders + headers := providerErr.ResponseHeaders var ms time.Duration // retry-ms is more precise than retry-after and used by e.g. OpenAI @@ -101,7 +74,7 @@ type RetryOptions struct { } // OnRetryCallback defines a function that is called when a retry occurs. -type OnRetryCallback = func(err *APICallError, delay time.Duration) +type OnRetryCallback = func(err *ProviderError, delay time.Duration) // DefaultRetryOptions returns the default retry options. // DefaultRetryOptions returns the default retry options. @@ -129,23 +102,18 @@ func retryWithExponentialBackoff[T any](ctx context.Context, fn RetryFn[T], opti return zero, err // don't wrap the error when retries are disabled } - errorMessage := err.Error() newErrors := append(allErrors, err) tryNumber := len(newErrors) if tryNumber > options.MaxRetries { - return zero, NewRetryError( - fmt.Sprintf("Failed after %d attempts. Last error: %v", tryNumber, errorMessage), - RetryReasonMaxRetriesExceeded, - newErrors, - ) + return zero, &RetryError{newErrors} } - var apiErr *APICallError - if errors.As(err, &apiErr) && apiErr.IsRetryable && tryNumber <= options.MaxRetries { + var providerErr *ProviderError + if errors.As(err, &providerErr) && providerErr.IsRetryable() && tryNumber <= options.MaxRetries { delay := getRetryDelayInMs(err, options.InitialDelayIn) if options.OnRetry != nil { - options.OnRetry(apiErr, delay) + options.OnRetry(providerErr, delay) } select { @@ -165,9 +133,5 @@ func retryWithExponentialBackoff[T any](ctx context.Context, fn RetryFn[T], opti return zero, err // don't wrap the error when a non-retryable error occurs on the first try } - return zero, NewRetryError( - fmt.Sprintf("Failed after %d attempts with non-retryable error: %v", tryNumber, errorMessage), - RetryReasonErrorNotRetryable, - newErrors, - ) + return zero, &RetryError{newErrors} } From bb0bc6232806f9cc69ac4ada21ebf6a01ec1dbad Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 6 Nov 2025 16:54:14 -0300 Subject: [PATCH 6/9] fix(google): return `*fantasy.ProviderError` when we should --- errors.go | 17 +++++++ providers/anthropic/anthropic.go | 28 +---------- providers/anthropic/error.go | 39 +++++++++++++++ providers/google/error.go | 23 +++++++++ providers/google/google.go | 4 +- providers/openai/error.go | 52 ++++++++++++++++++++ providers/openai/language_model.go | 32 ++---------- providers/openai/responses_language_model.go | 34 +++---------- 8 files changed, 145 insertions(+), 84 deletions(-) create mode 100644 providers/anthropic/error.go create mode 100644 providers/google/error.go create mode 100644 providers/openai/error.go diff --git a/errors.go b/errors.go index df7998e3ab717b2f287d2fb53a822d459c1c3dd2..0e843640cd771648bb89df64fe2cf3d02a2abcdd 100644 --- a/errors.go +++ b/errors.go @@ -68,3 +68,20 @@ func (e RetryError) Unwrap() error { } return nil } + +var statusCodeToTitle = map[int]string{ + http.StatusBadRequest: "bad request", + http.StatusUnauthorized: "authentication failed", + http.StatusForbidden: "permission denied", + http.StatusNotFound: "resource not found", + http.StatusTooManyRequests: "rate limit exceeded", + http.StatusInternalServerError: "internal server error", + http.StatusBadGateway: "bad gateway", + http.StatusServiceUnavailable: "service unavailable", + http.StatusGatewayTimeout: "gateway timeout", +} + +// ErrorTitleForStatusCode returns a human-readable title for a given HTTP status code. +func ErrorTitleForStatusCode(statusCode int) string { + return statusCodeToTitle[statusCode] +} diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 9b0fb97e3d7617685a31445cd9e1eab539348360..04e85dc819046f23dc258c464c9973f993ae429b 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -690,30 +690,6 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl return systemBlocks, messages, warnings } -func (a languageModel) handleError(err error) error { - var apiErr *anthropic.Error - if errors.As(err, &apiErr) { - requestDump := apiErr.DumpRequest(true) - responseDump := apiErr.DumpResponse(true) - headers := map[string]string{} - for k, h := range apiErr.Response.Header { - v := h[len(h)-1] - headers[strings.ToLower(k)] = v - } - return &fantasy.ProviderError{ - Title: "provider request failed", - Message: apiErr.Error(), - Cause: apiErr, - URL: apiErr.Request.URL.String(), - StatusCode: apiErr.StatusCode, - RequestBody: requestDump, - ResponseHeaders: headers, - ResponseBody: responseDump, - } - } - return err -} - func mapFinishReason(finishReason string) fantasy.FinishReason { switch finishReason { case "end_turn", "pause_turn", "stop_sequence": @@ -735,7 +711,7 @@ func (a languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantas } response, err := a.client.Messages.New(ctx, *params) if err != nil { - return nil, a.handleError(err) + return nil, toProviderErr(err) } var content []fantasy.Content @@ -968,7 +944,7 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S } else { //nolint: revive yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, - Error: a.handleError(err), + Error: toProviderErr(err), }) return } diff --git a/providers/anthropic/error.go b/providers/anthropic/error.go new file mode 100644 index 0000000000000000000000000000000000000000..38393b550a76562b9268d25bda3499b38c6ba236 --- /dev/null +++ b/providers/anthropic/error.go @@ -0,0 +1,39 @@ +package anthropic + +import ( + "cmp" + "errors" + "net/http" + "strings" + + "charm.land/fantasy" + "github.com/charmbracelet/anthropic-sdk-go" +) + +func toProviderErr(err error) error { + var apiErr *anthropic.Error + if errors.As(err, &apiErr) { + return &fantasy.ProviderError{ + Title: cmp.Or(fantasy.ErrorTitleForStatusCode(apiErr.StatusCode), "provider request failed"), + Message: apiErr.Error(), + Cause: apiErr, + URL: apiErr.Request.URL.String(), + StatusCode: apiErr.StatusCode, + RequestBody: apiErr.DumpRequest(true), + ResponseHeaders: toHeaderMap(apiErr.Response.Header), + ResponseBody: apiErr.DumpResponse(true), + } + } + return err +} + +func toHeaderMap(in http.Header) (out map[string]string) { + out = make(map[string]string, len(in)) + for k, v := range in { + if l := len(v); l > 0 { + out[k] = v[l-1] + in[strings.ToLower(k)] = v + } + } + return out +} diff --git a/providers/google/error.go b/providers/google/error.go new file mode 100644 index 0000000000000000000000000000000000000000..710cffff61217f0c69bf525f5efb7083b26c3e3d --- /dev/null +++ b/providers/google/error.go @@ -0,0 +1,23 @@ +package google + +import ( + "cmp" + "errors" + + "charm.land/fantasy" + "google.golang.org/genai" +) + +func toProviderErr(err error) error { + var apiErr genai.APIError + if !errors.As(err, &apiErr) { + return err + } + return &fantasy.ProviderError{ + Message: apiErr.Message, + Title: cmp.Or(fantasy.ErrorTitleForStatusCode(apiErr.Code), "provider request failed"), + Cause: err, + StatusCode: apiErr.Code, + ResponseBody: []byte(apiErr.Message), + } +} diff --git a/providers/google/google.go b/providers/google/google.go index 0434c9ea01db63452a6704cad1fe61093f56b4f9..c64cc379cb60467236cdad53923b1f3dfcc0fc0b 100644 --- a/providers/google/google.go +++ b/providers/google/google.go @@ -514,7 +514,7 @@ func (g *languageModel) Generate(ctx context.Context, call fantasy.Call) (*fanta response, err := chat.SendMessage(ctx, depointerSlice(lastMessage.Parts)...) if err != nil { - return nil, err + return nil, toProviderErr(err) } return g.mapResponse(response, warnings) @@ -571,7 +571,7 @@ func (g *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. if err != nil { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, - Error: err, + Error: toProviderErr(err), }) return } diff --git a/providers/openai/error.go b/providers/openai/error.go new file mode 100644 index 0000000000000000000000000000000000000000..fed072088b117f4c0e8cd28c38c3ee8c3bd73584 --- /dev/null +++ b/providers/openai/error.go @@ -0,0 +1,52 @@ +package openai + +import ( + "cmp" + "errors" + "io" + "net/http" + "strings" + + "charm.land/fantasy" + "github.com/openai/openai-go/v2" +) + +func toProviderErr(err error) error { + var apiErr *openai.Error + if errors.As(err, &apiErr) { + return &fantasy.ProviderError{ + Title: cmp.Or(fantasy.ErrorTitleForStatusCode(apiErr.StatusCode), "provider request failed"), + Message: toProviderErrMessage(apiErr), + Cause: apiErr, + URL: apiErr.Request.URL.String(), + StatusCode: apiErr.StatusCode, + RequestBody: apiErr.DumpRequest(true), + ResponseHeaders: toHeaderMap(apiErr.Response.Header), + ResponseBody: apiErr.DumpResponse(true), + } + } + return err +} + +func toProviderErrMessage(apiErr *openai.Error) string { + if apiErr.Message != "" { + return apiErr.Message + } + + // For some OpenAI-compatible providers, the SDK is not always able to parse + // the error message correctly. + // Fallback to returning the raw response body in such cases. + data, _ := io.ReadAll(apiErr.Response.Body) + return string(data) +} + +func toHeaderMap(in http.Header) (out map[string]string) { + out = make(map[string]string, len(in)) + for k, v := range in { + if l := len(v); l > 0 { + out[k] = v[l-1] + in[strings.ToLower(k)] = v + } + } + return out +} diff --git a/providers/openai/language_model.go b/providers/openai/language_model.go index d0b8212f6e00b2cb1e94d3dc23d42e00ef2b6ede..ef85d2ccf0c35dd46bf09bb2cb26b768a43b725a 100644 --- a/providers/openai/language_model.go +++ b/providers/openai/language_model.go @@ -223,30 +223,6 @@ func (o languageModel) prepareParams(call fantasy.Call) (*openai.ChatCompletionN return params, warnings, nil } -func (o languageModel) handleError(err error) error { - var apiErr *openai.Error - if errors.As(err, &apiErr) { - requestDump := apiErr.DumpRequest(true) - responseDump := apiErr.DumpResponse(true) - headers := map[string]string{} - for k, h := range apiErr.Response.Header { - v := h[len(h)-1] - headers[strings.ToLower(k)] = v - } - return &fantasy.ProviderError{ - Title: "provider request failed", - Message: apiErr.Message, - Cause: apiErr, - URL: apiErr.Request.URL.String(), - StatusCode: apiErr.StatusCode, - RequestBody: requestDump, - ResponseHeaders: headers, - ResponseBody: responseDump, - } - } - return err -} - // Generate implements fantasy.LanguageModel. func (o languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { params, warnings, err := o.prepareParams(call) @@ -255,11 +231,11 @@ func (o languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantas } response, err := o.client.Chat.Completions.New(ctx, *params) if err != nil { - return nil, o.handleError(err) + return nil, toProviderErr(err) } if len(response.Choices) == 0 { - return nil, errors.New("no response generated") + return nil, &fantasy.Error{Title: "no response", Message: "no response generated"} } choice := response.Choices[0] content := make([]fantasy.Content, 0, 1+len(choice.Message.ToolCalls)+len(choice.Message.Annotations)) @@ -433,7 +409,7 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S if err != nil { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, - Error: o.handleError(stream.Err()), + Error: toProviderErr(stream.Err()), }) return } @@ -563,7 +539,7 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S } else { //nolint: revive yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, - Error: o.handleError(err), + Error: toProviderErr(err), }) return } diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index 2f69551011d14df85b29a0f89ad7ae06a10eec48..9d90cf78bc7f4776599cd71b982d489227d59d2c 100644 --- a/providers/openai/responses_language_model.go +++ b/providers/openai/responses_language_model.go @@ -4,7 +4,6 @@ import ( "context" "encoding/base64" "encoding/json" - "errors" "fmt" "strings" @@ -649,39 +648,18 @@ func toResponsesTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice, opti return openaiTools, openaiToolChoice, warnings } -func (o responsesLanguageModel) handleError(err error) error { - var apiErr *openai.Error - if errors.As(err, &apiErr) { - requestDump := apiErr.DumpRequest(true) - responseDump := apiErr.DumpResponse(true) - headers := map[string]string{} - for k, h := range apiErr.Response.Header { - v := h[len(h)-1] - headers[strings.ToLower(k)] = v - } - return &fantasy.ProviderError{ - Title: "provider request failed", - Message: apiErr.Message, - Cause: apiErr, - URL: apiErr.Request.URL.String(), - StatusCode: apiErr.StatusCode, - RequestBody: requestDump, - ResponseHeaders: headers, - ResponseBody: responseDump, - } - } - return err -} - func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { params, warnings := o.prepareParams(call) response, err := o.client.Responses.New(ctx, *params) if err != nil { - return nil, o.handleError(err) + return nil, toProviderErr(err) } if response.Error.Message != "" { - return nil, o.handleError(fmt.Errorf("response error: %s (code: %s)", response.Error.Message, response.Error.Code)) + return nil, &fantasy.Error{ + Title: "provider error", + Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code), + } } var content []fantasy.Content @@ -1023,7 +1001,7 @@ func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) ( if err != nil { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, - Error: o.handleError(err), + Error: toProviderErr(err), }) return } From 9e774431dd8b5b45d7a26b6b187266486ed18835 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 6 Nov 2025 17:08:03 -0300 Subject: [PATCH 7/9] fix: disable sdk retries because we handle retries ourselves --- providers/anthropic/anthropic.go | 2 ++ providers/openai/openai.go | 1 + 2 files changed, 3 insertions(+) diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 04e85dc819046f23dc258c464c9973f993ae429b..86c0e03c96e7a10cac7145620c9e6461c6a035f2 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -122,6 +122,8 @@ func WithHTTPClient(client option.HTTPClient) Option { func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.LanguageModel, error) { clientOptions := make([]option.RequestOption, 0, 5+len(a.options.headers)) + clientOptions = append(clientOptions, option.WithMaxRetries(0)) + if a.options.apiKey != "" && !a.options.useBedrock { clientOptions = append(clientOptions, option.WithAPIKey(a.options.apiKey)) } diff --git a/providers/openai/openai.go b/providers/openai/openai.go index f3f20e2751ed1629afb41b169e3dc483ccb9ae57..7f0e8f14d3d95b57ea91f080af50d8fa364d8a27 100644 --- a/providers/openai/openai.go +++ b/providers/openai/openai.go @@ -134,6 +134,7 @@ func WithUseResponsesAPI() Option { // LanguageModel implements fantasy.Provider. func (o *provider) LanguageModel(_ context.Context, modelID string) (fantasy.LanguageModel, error) { openaiClientOptions := make([]option.RequestOption, 0, 5+len(o.options.headers)+len(o.options.sdkOptions)) + openaiClientOptions = append(openaiClientOptions, option.WithMaxRetries(0)) if o.options.apiKey != "" { openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(o.options.apiKey)) From a54f50d2a6883e8ba6f1098bdc17a390ee3293f3 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 7 Nov 2025 10:09:39 -0300 Subject: [PATCH 8/9] chore: use `http.StatusText` Co-authored-by: Christian Rocha --- errors.go | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/errors.go b/errors.go index 0e843640cd771648bb89df64fe2cf3d02a2abcdd..479b0c1a45eefe84bfcf11064865eb44b80f5b5f 100644 --- a/errors.go +++ b/errors.go @@ -3,6 +3,7 @@ package fantasy import ( "fmt" "net/http" + "strings" "github.com/charmbracelet/x/exp/slice" ) @@ -69,19 +70,7 @@ func (e RetryError) Unwrap() error { return nil } -var statusCodeToTitle = map[int]string{ - http.StatusBadRequest: "bad request", - http.StatusUnauthorized: "authentication failed", - http.StatusForbidden: "permission denied", - http.StatusNotFound: "resource not found", - http.StatusTooManyRequests: "rate limit exceeded", - http.StatusInternalServerError: "internal server error", - http.StatusBadGateway: "bad gateway", - http.StatusServiceUnavailable: "service unavailable", - http.StatusGatewayTimeout: "gateway timeout", -} - // ErrorTitleForStatusCode returns a human-readable title for a given HTTP status code. func ErrorTitleForStatusCode(statusCode int) string { - return statusCodeToTitle[statusCode] + return strings.ToLower(http.StatusText(statusCode)) }