Files

139 lines
3.2 KiB
Go
Raw Permalink Normal View History

package core
import (
"context"
"errors"
"fmt"
"math"
"math/rand"
"time"
"github.com/sirupsen/logrus"
)
// RetryConfig controls retry behavior.
type RetryConfig struct {
MaxRetries int
InitialBackoff time.Duration
MaxBackoff time.Duration
BackoffFactor float64
}
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: 3,
InitialBackoff: time.Second,
MaxBackoff: 30 * time.Second,
BackoffFactor: 2.0,
}
}
type RetryResult struct {
Results []SearchResult
Err error
Attempts int
Engine string
}
// RetryableSearch executes searchFn with exponential backoff retries.
// CAPTCHA, block, rate-limit, parser, engine-internal, and proxy-unavailable errors are not retried.
func RetryableSearch(ctx context.Context, cfg RetryConfig, engineName string, searchFn func(context.Context) ([]SearchResult, error)) RetryResult {
ctx = WithEngine(EnsureContext(ctx), engineName)
logger := WithRequest(ctx)
if cfg.BackoffFactor <= 0 {
cfg.BackoffFactor = 2.0
}
var lastErr error
for attempt := 0; attempt <= cfg.MaxRetries; attempt++ {
if err := ctx.Err(); err != nil {
return RetryResult{
Err: err,
Attempts: attempt,
Engine: engineName,
}
}
if attempt > 0 {
backoff := calculateBackoff(cfg, attempt)
logger.WithFields(logrus.Fields{
"attempt": attempt,
"backoff": backoff.String(),
}).Warnf("Retry %d/%d after %s", attempt, cfg.MaxRetries, backoff)
if err := SleepContext(ctx, backoff); err != nil {
return RetryResult{
Err: err,
Attempts: attempt,
Engine: engineName,
}
}
}
results, err := searchFn(ctx)
if err == nil {
return RetryResult{
Results: results,
Attempts: attempt + 1,
Engine: engineName,
}
}
lastErr = err
if reason, skip := nonRetryableReason(err); skip {
logger.Warnf("%s, skipping retries", reason)
return RetryResult{
Err: err,
Attempts: attempt + 1,
Engine: engineName,
}
}
logger.WithField("attempt", attempt+1).Debugf("Attempt %d failed: %s", attempt+1, err)
}
return RetryResult{
Err: fmt.Errorf("all %d attempts failed for %s: %w", cfg.MaxRetries+1, engineName, lastErr),
Attempts: cfg.MaxRetries + 1,
Engine: engineName,
}
}
var nonRetryableSentinels = []struct {
err error
reason string
}{
{ErrCaptcha, "CAPTCHA detected"},
{ErrBlocked, "Blocked response detected"},
{ErrRateLimited, "Rate limited response detected"},
{ErrProxyUnavailable, "Proxy unavailable"},
{ErrParser, "Parser failure"},
{ErrEngineInternal, "Engine panic recovered"},
}
func nonRetryableReason(err error) (string, bool) {
for _, s := range nonRetryableSentinels {
if errors.Is(err, s.err) {
return s.reason, true
}
}
if IsContextDone(err) {
return "Context canceled/deadline exceeded", true
}
return "", false
}
func calculateBackoff(cfg RetryConfig, attempt int) time.Duration {
backoff := float64(cfg.InitialBackoff) * math.Pow(cfg.BackoffFactor, float64(attempt-1))
if backoff > float64(cfg.MaxBackoff) {
backoff = float64(cfg.MaxBackoff)
}
if backoff < 0 {
backoff = 0
}
backoff = backoff * (0.5 + rand.Float64())
if backoff > float64(cfg.MaxBackoff) {
backoff = float64(cfg.MaxBackoff)
}
return time.Duration(backoff)
}