Skip to content
Open
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
103 changes: 103 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,109 @@
// }
// }
//
// # Automatic Retry
//
// LangchainGo provides a built-in retry mechanism for transient HTTP failures.
// Retry is opt-in: it is only enabled when a RetryConfig is explicitly provided
// via the WithRetryConfig option. Without it, behavior is unchanged.
//
// ## What gets retried
//
// The following transient failures are automatically retried:
//
// - HTTP 429 (Rate Limit) — respects the Retry-After response header
// - HTTP 500, 502, 503, 504 (Server Errors)
// - Network errors (connection refused, DNS failure, TLS timeout)
// - Provider-specific body-level errors (e.g., ERNIE's HTTP 200 + error_code:18)
//
// The following are NOT retried:
//
// - HTTP 400, 401, 403, 404 and other 4xx client errors
// - Context cancellation (context.Canceled)
// - Context deadline exceeded (context.DeadlineExceeded)
// - Successful responses
//
// ## Basic usage
//
// All LLM providers support retry via the WithRetryConfig option:
//
// import "github.com/tmc/langchaingo/httputil"
//
// // Use sensible defaults: 3 retries, 1s initial backoff, 30s max backoff
// llm, err := openai.New(
// openai.WithToken("sk-xxx"),
// openai.WithRetryConfig(httputil.DefaultRetryConfig()),
// )
//
// The same pattern works for all providers:
//
// llm, _ := anthropic.New(anthropic.WithRetryConfig(cfg), ...)
// llm, _ := ernie.New(ernie.WithRetryConfig(cfg), ...)
// llm, _ := ollama.New(ollama.WithRetryConfig(cfg), ...)
// llm, _ := cohere.New(cohere.WithRetryConfig(cfg), ...)
// llm, _ := cloudflare.New(cloudflare.WithRetryConfig(cfg), ...)
// llm, _ := huggingface.New(huggingface.WithRetryConfig(cfg), ...)
// llm, _ := llamafile.New(llamafile.WithRetryConfig(cfg), ...)
// llm, _ := maritaca.New(maritaca.WithRetryConfig(cfg), ...)
//
// ## Custom configuration
//
// llm, err := openai.New(
// openai.WithToken("sk-xxx"),
// openai.WithRetryConfig(&httputil.RetryConfig{
// MaxRetries: 5,
// InitialBackoff: 2 * time.Second,
// MaxBackoff: 60 * time.Second,
// BackoffFactor: 2.0,
// }),
// )
//
// The backoff sequence with Factor 2.0 and InitialBackoff 2s is:
//
// attempt 0: 2s (initial)
// attempt 1: 4s (2s × 2.0)
// attempt 2: 8s (4s × 2.0)
// attempt 3: 16s (8s × 2.0)
// attempt 4: 32s (capped at MaxBackoff 60s)
//
// Random jitter is applied to prevent thundering herd.
//
// ## Retry-After header support
//
// When a provider returns HTTP 429 with a Retry-After header, the retry
// mechanism waits the server-specified duration instead of the computed backoff:
//
// // Server returns: HTTP 429 + Retry-After: 60
// // Wait duration: max(computed_backoff, 60s) = 60s
//
// ## Logging retries
//
// Use the OnRetry callback for observability:
//
// llm, err := openai.New(
// openai.WithToken("sk-xxx"),
// openai.WithRetryConfig(&httputil.RetryConfig{
// MaxRetries: 3,
// InitialBackoff: 1 * time.Second,
// MaxBackoff: 30 * time.Second,
// BackoffFactor: 2.0,
// OnRetry: func(attempt int, err error) {
// slog.Warn("retrying request", "attempt", attempt+1, "error", err)
// },
// }),
// )
//
// ## Custom retry conditions
//
// Override the default retryable status codes or error checks:
//
// cfg := httputil.DefaultRetryConfig()
//
// // Also retry HTTP 408 (Request Timeout)
// cfg.RetryableStatus = func(code int) bool {
// return code == 408 || code == 429 || (code >= 500 && code <= 504)
// }
//
// # Testing
//
// LangchainGo includes comprehensive testing utilities including HTTP record/replay for internal tests.
Expand Down
135 changes: 135 additions & 0 deletions httputil/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,139 @@
// The transports in this package are designed to work with the httprr
// HTTP record/replay system used in tests. When using httprr, pass
// httputil.DefaultTransport to ensure proper request interception.
//
// # Automatic Retry
//
// The package provides a unified retry mechanism for transient HTTP failures.
// It is opt-in: retry is only enabled when a RetryConfig is explicitly provided.
//
// ## What gets retried
//
// The following transient failures are automatically retried:
//
// - HTTP 429 (Rate Limit) — respects the Retry-After response header
// - HTTP 500, 502, 503, 504 (Server Errors)
// - Network errors (connection refused, DNS failure, TLS timeout)
// - Provider-specific body-level errors (e.g., ERNIE's HTTP 200 + error_code:18)
//
// The following are NOT retried:
//
// - HTTP 400, 401, 403, 404 and other 4xx client errors
// - Context cancellation (context.Canceled)
// - Context deadline exceeded (context.DeadlineExceeded)
// - Successful responses
//
// ## Basic usage
//
// All LLM providers support retry via the WithRetryConfig option:
//
// import "github.com/tmc/langchaingo/httputil"
//
// // Use sensible defaults: 3 retries, 1s initial backoff, 30s max backoff
// llm, err := openai.New(
// openai.WithToken("sk-xxx"),
// openai.WithRetryConfig(httputil.DefaultRetryConfig()),
// )
//
// The same pattern works for all providers:
//
// llm, _ := anthropic.New(anthropic.WithRetryConfig(cfg), ...)
// llm, _ := ernie.New(ernie.WithRetryConfig(cfg), ...)
// llm, _ := ollama.New(ollama.WithRetryConfig(cfg), ...)
// llm, _ := cohere.New(cohere.WithRetryConfig(cfg), ...)
// llm, _ := cloudflare.New(cloudflare.WithRetryConfig(cfg), ...)
// llm, _ := huggingface.New(huggingface.WithRetryConfig(cfg), ...)
// llm, _ := llamafile.New(llamafile.WithRetryConfig(cfg), ...)
// llm, _ := maritaca.New(maritaca.WithRetryConfig(cfg), ...)
//
// When WithRetryConfig is not provided, no retry is performed (the default
// behavior is unchanged).
//
// ## Custom configuration
//
// llm, err := openai.New(
// openai.WithToken("sk-xxx"),
// openai.WithRetryConfig(&httputil.RetryConfig{
// MaxRetries: 5,
// InitialBackoff: 2 * time.Second,
// MaxBackoff: 60 * time.Second,
// BackoffFactor: 2.0,
// }),
// )
//
// The backoff sequence with Factor 2.0 and InitialBackoff 2s is:
//
// attempt 0: 2s (initial)
// attempt 1: 4s (2s × 2.0)
// attempt 2: 8s (4s × 2.0)
// attempt 3: 16s (8s × 2.0)
// attempt 4: 32s (capped at MaxBackoff 60s)
//
// Random jitter (±50%) is applied to prevent thundering herd.
//
// ## Retry-After header support
//
// When a provider returns HTTP 429 with a Retry-After header, the retry
// mechanism waits the duration specified by the server instead of the
// computed backoff:
//
// // Server returns: HTTP 429 + Retry-After: 60
// // Wait duration: max(computed_backoff, 60s) = 60s
//
// This applies to OpenAI, Anthropic, and ERNIE providers.
//
// ## Logging retries
//
// Use the OnRetry callback for observability:
//
// llm, err := openai.New(
// openai.WithToken("sk-xxx"),
// openai.WithRetryConfig(&httputil.RetryConfig{
// MaxRetries: 3,
// InitialBackoff: 1 * time.Second,
// MaxBackoff: 30 * time.Second,
// BackoffFactor: 2.0,
// OnRetry: func(attempt int, err error) {
// slog.Warn("retrying request",
// "attempt", attempt+1,
// "error", err,
// )
// },
// }),
// )
//
// ## Custom retry conditions
//
// Override the default retryable status codes or error checks:
//
// cfg := httputil.DefaultRetryConfig()
//
// // Also retry HTTP 408 (Request Timeout)
// cfg.RetryableStatus = func(code int) bool {
// return code == 408 || code == 429 || (code >= 500 && code <= 504)
// }
//
// // Custom network error check
// cfg.RetryableError = func(err error) bool {
// return myCustomCheck(err)
// }
//
// ## Two internal mechanisms
//
// The package provides two retry implementations. Users do not need to
// choose — the appropriate one is selected automatically by each provider:
//
// - RetryOnError (application-layer): Used by OpenAI, Anthropic, and ERNIE.
// Retries based on network errors, HTTP status codes, AND provider-specific
// body-level errors (e.g., ERNIE returns HTTP 200 with error_code in the
// response body). Also respects Retry-After headers.
//
// - RetryTransport (transport-layer): Used by Ollama, Cohere, Cloudflare,
// HuggingFace, Llamafile, and Maritaca. Retries based on HTTP status codes
// and network errors at the http.RoundTripper level. Also respects
// Retry-After headers.
//
// Both mechanisms share the same RetryConfig, backoff algorithm, and jitter
// strategy. The only difference is that RetryOnError can additionally detect
// body-level errors via provider-specific MapError functions.
package httputil
71 changes: 71 additions & 0 deletions httputil/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package httputil

import (
"fmt"
"net/http"
"strconv"
"time"
)

// ResponseError represents an HTTP error response that carries metadata
// useful for retry decisions, including the Retry-After header value.
//
// Provider internal clients should return *ResponseError when they receive
// non-200 HTTP responses so that [RetryOnError] can respect Retry-After.
type ResponseError struct {
// StatusCode is the HTTP status code.
StatusCode int

// Message is the error message (typically includes status code and API body).
Message string

// RetryAfter is the duration indicated by the Retry-After response header.
// Zero means the header was absent or could not be parsed.
RetryAfter time.Duration
}

// Error implements the error interface.
func (e *ResponseError) Error() string { return e.Message }

// ParseRetryAfterHeader extracts the Retry-After duration from an HTTP response.
// Returns 0 if the header is absent or cannot be parsed.
func ParseRetryAfterHeader(resp *http.Response) time.Duration {
val := resp.Header.Get("Retry-After")
if val == "" {
return 0
}

// Try parsing as integer seconds.
if seconds, err := strconv.Atoi(val); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}

// Try parsing as HTTP date.
if t, err := http.ParseTime(val); err == nil {
d := time.Until(t)
if d > 0 {
return d
}
}

return 0
}

// NewResponseError creates a ResponseError from an HTTP response.
// The body should be pre-read; this function only extracts the status code
// and Retry-After header.
func NewResponseError(resp *http.Response, message string) *ResponseError {
return &ResponseError{
StatusCode: resp.StatusCode,
Message: message,
RetryAfter: ParseRetryAfterHeader(resp),
}
}

// FormatHTTPError formats a standard HTTP error message with status code and optional body.
func FormatHTTPError(statusCode int, body string) string {
if body != "" {
return fmt.Sprintf("API returned unexpected status code: %d: %s", statusCode, body)
}
return fmt.Sprintf("API returned unexpected status code: %d", statusCode)
}
Loading
Loading