mirror of
https://github.com/karust/openserp.git
synced 2026-08-05 16:53:54 +08:00
139 lines
3.2 KiB
Go
139 lines
3.2 KiB
Go
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)
|
|
}
|