Files
openserp/core/resilient.go
Rustem Kamalov 16d3a844ad feat: v1 response envelope, normalized Result struct, clusters, and enrichment
- All search endpoints now return a JSON envelope with query echo,
  meta (request_id, took_ms, engines_responded, engines_failed, version),
  results array, and pagination fields
- SearchResult renamed to RawResult internally; new Result struct adds
  id, type, display_url, domain, favicon, is_ad, position, engine_meta,
  domain_info, classification
- Result IDs are stable SHA-256 hashes of engine+normalized_url
- Bing redirect URLs (bing.com/ck/a) are unwrapped before hashing
- ResultType enum defined: organic, ad, answer_box, featured_snippet, etc.
- /mega/search response includes clusters array grouping same-URL results
  across engines with score, best_rank, and occurrences
- engines_failed field surfaces partial failures in megasearch
- Lightweight domain enrichment: TLD/SLD, is_gov/edu/mil/news/forum/
  marketplace/social flags, content_type and source_hint classification
- SearchAllParallel/SearchAllImageParallel now return (results, responded, failed)
- Image endpoints return ImageEnvelope with dedicated ImageResult shape
2026-04-26 02:57:59 +03:00

455 lines
13 KiB
Go

package core
import (
"context"
"errors"
"fmt"
"strings"
"github.com/sirupsen/logrus"
)
// ResilientSearcher wraps engines with retry and circuit breaker protection.
type ResilientSearcher struct {
engines []SearchEngine
cbManager *CircuitBreakerManager
retryCfg RetryConfig
proxyRuntime string
proxyCfg ProxyConfig
proxyRegistry *ProxyRegistry
proxyDefaults ProxyPolicy
effectivePolicies map[string]ProxyPolicy
}
type ProxyExecutionMeta struct {
Mode string `json:"mode"`
Tag string `json:"tag,omitempty"`
Used string `json:"used"`
}
type ResilientConfig struct {
Retry RetryConfig
CircuitBreaker CircuitBreakerConfig
Proxy ProxyConfig
}
func DefaultResilientConfig() ResilientConfig {
return ResilientConfig{
Retry: DefaultRetryConfig(),
CircuitBreaker: DefaultCircuitBreakerConfig(),
Proxy: DefaultProxyConfig(),
}
}
func NewResilientSearcher(engines []SearchEngine, cfg ResilientConfig) *ResilientSearcher {
proxyCfg, err := NormalizeProxyConfig(cfg.Proxy)
if err != nil {
logrus.WithError(err).Error("Invalid proxy config, using defaults")
proxyCfg = DefaultProxyConfig()
proxyCfg, _ = NormalizeProxyConfig(proxyCfg)
}
rs := &ResilientSearcher{
engines: engines,
cbManager: NewCircuitBreakerManager(cfg.CircuitBreaker),
retryCfg: cfg.Retry,
proxyRuntime: proxyCfg.Runtime,
proxyCfg: proxyCfg,
proxyRegistry: proxyCfg.Registry,
proxyDefaults: ResolveEffectiveProxyPolicy(proxyCfg.Proxies.Global, ""),
effectivePolicies: make(map[string]ProxyPolicy, len(engines)),
}
for _, engine := range engines {
engineName := normalizeEngineName(engine.Name())
override := proxyCfg.EnginePolicies[engineName]
effective := ResolveEffectiveProxyPolicy(proxyCfg.Proxies.Global, override)
rs.effectivePolicies[engineName] = effective
}
return rs
}
// SearchPrimary keeps dedicated endpoints engine-pure (no fallback).
func (rs *ResilientSearcher) SearchPrimary(ctx context.Context, primaryEngine SearchEngine, q Query) ([]SearchResult, string, ProxyExecutionMeta, error) {
results, proxyMeta, err := rs.searchWithProtection(ctx, primaryEngine, q, false)
if err != nil {
return nil, primaryEngine.Name(), proxyMeta, err
}
return results, primaryEngine.Name(), proxyMeta, nil
}
// SearchWithFallback retries primary and then tries other initialized engines.
func (rs *ResilientSearcher) SearchWithFallback(ctx context.Context, primaryEngine SearchEngine, q Query) ([]SearchResult, string, ProxyExecutionMeta, error) {
return rs.searchWithFallback(ctx, primaryEngine, q, false)
}
func (rs *ResilientSearcher) SearchImagePrimary(ctx context.Context, primaryEngine SearchEngine, q Query) ([]SearchResult, string, ProxyExecutionMeta, error) {
results, proxyMeta, err := rs.searchWithProtection(ctx, primaryEngine, q, true)
if err != nil {
return nil, primaryEngine.Name(), proxyMeta, err
}
return results, primaryEngine.Name(), proxyMeta, nil
}
func (rs *ResilientSearcher) SearchImageWithFallback(ctx context.Context, primaryEngine SearchEngine, q Query) ([]SearchResult, string, ProxyExecutionMeta, error) {
return rs.searchWithFallback(ctx, primaryEngine, q, true)
}
func (rs *ResilientSearcher) searchWithFallback(ctx context.Context, primaryEngine SearchEngine, q Query, isImage bool) ([]SearchResult, string, ProxyExecutionMeta, error) {
ctx = EnsureContext(ctx)
results, proxyMeta, err := rs.searchWithProtection(ctx, primaryEngine, q, isImage)
if err == nil {
return results, primaryEngine.Name(), proxyMeta, nil
}
if ctx.Err() != nil {
return nil, primaryEngine.Name(), proxyMeta, ctx.Err()
}
if errors.Is(err, ErrProxyUnavailable) {
WithRequestEngine(ctx, primaryEngine.Name()).WithError(err).Warn("Proxy policy failed closed")
return nil, primaryEngine.Name(), proxyMeta, err
}
successMessage := "Fallback to %s succeeded with %d results"
if isImage {
successMessage = "Image fallback to %s succeeded with %d results"
}
WithRequestEngine(ctx, primaryEngine.Name()).
WithError(err).
Warn("Primary engine failed, trying fallbacks")
for _, fallbackEngine := range rs.engines {
if ctx.Err() != nil {
return nil, primaryEngine.Name(), proxyMeta, ctx.Err()
}
if fallbackEngine.Name() == primaryEngine.Name() || !fallbackEngine.IsInitialized() {
continue
}
results, fallbackMeta, fallbackErr := rs.searchWithProtection(ctx, fallbackEngine, q, isImage)
if fallbackErr == nil {
WithRequestEngine(ctx, fallbackEngine.Name()).
WithField("results_count", len(results)).
Infof(successMessage, fallbackEngine.Name(), len(results))
return results, fallbackEngine.Name(), fallbackMeta, nil
}
WithRequestEngine(ctx, fallbackEngine.Name()).WithError(fallbackErr).Debug("Fallback engine also failed")
}
return nil, primaryEngine.Name(), proxyMeta, ErrAllEnginesFailed
}
func (rs *ResilientSearcher) searchWithProtection(ctx context.Context, engine SearchEngine, q Query, isImage bool) ([]SearchResult, ProxyExecutionMeta, error) {
ctx = EnsureContext(ctx)
if ctx.Err() != nil {
return nil, ProxyExecutionMeta{}, ctx.Err()
}
cb := rs.cbManager.Get(engine.Name())
engineCtx := WithEngine(ctx, engine.Name())
if !cb.AllowRequest(engineCtx) {
return nil, ProxyExecutionMeta{}, ErrCircuitOpen
}
policy := rs.effectivePolicyForQuery(engine.Name(), q)
attemptMeta := rs.baseProxyMeta(policy)
result := RetryableSearch(ctx, rs.retryCfg, engine.Name(), func(callCtx context.Context) ([]SearchResult, error) {
limiter := engine.GetRateLimiter()
if limiter != nil {
if err := limiter.Wait(callCtx); err != nil {
return nil, err
}
}
attemptQuery := q
proxyURL := ""
reportToRegistry := false
attemptMeta = rs.baseProxyMeta(policy)
switch policy.Mode {
case ProxyModeOff:
attemptQuery.ProxyURL = ""
attemptMeta.Used = "direct"
case ProxyModeTagPool:
proxyURL = rs.selectProxyForQuery(policy, q, engineCtx)
if proxyURL == "" {
return nil, fmt.Errorf("%w: no healthy proxy available for tag %q", ErrProxyUnavailable, policy.Tag)
}
attemptQuery.ProxyURL = proxyURL
reportToRegistry = policy.Tag != ""
attemptMeta.Used = MaskProxyURL(proxyURL)
}
var (
results []SearchResult
err error
)
if isImage {
results, err = engine.SearchImage(callCtx, attemptQuery)
} else {
results, err = engine.Search(callCtx, attemptQuery)
}
if reportToRegistry {
rs.reportProxyAttempt(engineCtx, proxyURL, err)
}
return results, err
})
if result.Err != nil {
if !errors.Is(result.Err, ErrProxyUnavailable) {
cb.RecordFailure(engineCtx)
}
return nil, attemptMeta, result.Err
}
cb.RecordSuccess(engineCtx)
return result.Results, attemptMeta, nil
}
// SearchAllParallel applies retry/circuit protections per engine for mega search.
// Returns results, list of engines that responded, and list of engines that failed.
func (rs *ResilientSearcher) SearchAllParallel(ctx context.Context, q Query, engines []SearchEngine) ([]MegaSearchResult, []string, []string) {
return rs.runParallel(ctx, q, engines, false)
}
func (rs *ResilientSearcher) SearchAllImageParallel(ctx context.Context, q Query, engines []SearchEngine) ([]MegaSearchResult, []string, []string) {
return rs.runParallel(ctx, q, engines, true)
}
func (rs *ResilientSearcher) runParallel(ctx context.Context, q Query, engines []SearchEngine, isImage bool) ([]MegaSearchResult, []string, []string) {
ctx = EnsureContext(ctx)
type engineResult struct {
name string
results []MegaSearchResult
err error
}
resultCh := make(chan engineResult, len(engines))
started := 0
for _, engine := range engines {
if ctx.Err() != nil {
break
}
if !engine.IsInitialized() {
resultCh <- engineResult{name: engine.Name(), err: fmt.Errorf("not initialized")}
started++
continue
}
engineCtx := WithEngine(ctx, engine.Name())
if !rs.cbManager.Get(engine.Name()).AllowRequest(engineCtx) {
WithRequest(engineCtx).Debug("Skipping engine in mega search: circuit open")
resultCh <- engineResult{name: engine.Name(), err: ErrCircuitOpen}
started++
continue
}
started++
go func(eng SearchEngine) {
results, _, err := rs.searchWithProtection(ctx, eng, q, isImage)
if err != nil {
resultCh <- engineResult{name: eng.Name(), err: err}
return
}
mega := make([]MegaSearchResult, len(results))
for i, r := range results {
mega[i] = MegaSearchResult{SearchResult: r, Engine: eng.Name()}
}
resultCh <- engineResult{name: eng.Name(), results: mega}
}(engine)
}
var allResults []MegaSearchResult
var responded, failed []string
for i := 0; i < started; i++ {
res := <-resultCh
if res.err != nil {
failed = append(failed, res.name)
} else {
responded = append(responded, res.name)
allResults = append(allResults, res.results...)
}
}
if responded == nil {
responded = []string{}
}
if failed == nil {
failed = []string{}
}
return allResults, responded, failed
}
func (rs *ResilientSearcher) GetCircuitBreakerStats() []map[string]interface{} {
return rs.cbManager.AllStats()
}
func (rs *ResilientSearcher) GetProxyStats() ProxyStats {
stats := ProxyStats{
ConfiguredCount: 0,
HealthyCount: 0,
UnhealthyCount: 0,
Tags: map[string]ProxyTagSummary{},
Entries: []ProxyStatsEntry{},
}
if rs.proxyRegistry != nil {
stats = rs.proxyRegistry.BuildStats()
}
engines := map[string]ProxyEngineStats{}
for _, engine := range rs.engines {
engineName := normalizeEngineName(engine.Name())
policy := rs.effectivePolicyForEngine(engineName)
engineStats := ProxyEngineStats{}
switch policy.Mode {
case ProxyModeOff:
engineStats.SelectedProxy = "direct"
case ProxyModeTagPool:
engineStats.Tag = policy.Tag
if global := strings.TrimSpace(rs.proxyCfg.Proxies.Global); global != "" {
engineStats.SelectedProxy = MaskProxyURL(global)
} else {
engineStats.SelectedProxy = "pooled"
}
}
engines[engineName] = engineStats
}
if len(engines) > 0 {
stats.Engines = engines
}
return stats
}
func (rs *ResilientSearcher) ResolveMegaProxyMeta(q Query, engines []SearchEngine) ProxyExecutionMeta {
if len(engines) == 0 {
return ProxyExecutionMeta{Mode: ProxyModeOff, Used: "direct"}
}
allOff := true
proxiedTags := map[string]struct{}{}
hasOff := false
for _, engine := range engines {
policy := rs.effectivePolicyForQuery(engine.Name(), q)
if policy.Mode == ProxyModeOff {
hasOff = true
continue
}
allOff = false
if policy.Tag != "" {
proxiedTags[policy.Tag] = struct{}{}
}
}
if allOff {
return ProxyExecutionMeta{Mode: ProxyModeOff, Used: "direct"}
}
meta := ProxyExecutionMeta{Mode: ProxyModeTagPool}
if len(proxiedTags) == 1 {
for tag := range proxiedTags {
meta.Tag = tag
}
}
if q.ProxyOverride == "" {
if global := strings.TrimSpace(rs.proxyCfg.Proxies.Global); global != "" && !hasOff {
meta.Used = MaskProxyURL(global)
return meta
}
}
if rs.proxyRuntime == ProxyRuntimeRaw {
meta.Used = "multiple"
if hasOff {
meta.Used = "mixed"
}
return meta
}
meta.Used = "multiple"
if hasOff {
meta.Used = "mixed"
}
return meta
}
func (rs *ResilientSearcher) baseProxyMeta(policy ProxyPolicy) ProxyExecutionMeta {
meta := ProxyExecutionMeta{Mode: policy.Mode}
if policy.Mode == ProxyModeTagPool {
meta.Tag = policy.Tag
return meta
}
meta.Used = "direct"
return meta
}
func (rs *ResilientSearcher) effectivePolicyForEngine(engineName string) ProxyPolicy {
engineName = normalizeEngineName(engineName)
if policy, ok := rs.effectivePolicies[engineName]; ok {
return policy
}
return rs.proxyDefaults
}
func (rs *ResilientSearcher) effectivePolicyForQuery(engineName string, q Query) ProxyPolicy {
switch q.ProxyOverride {
case "":
return rs.effectivePolicyForEngine(engineName)
case ProxyOverrideDirect:
return ProxyPolicy{Mode: ProxyModeOff}
default:
return ProxyPolicy{Mode: ProxyModeTagPool, Tag: q.ProxyOverride}
}
}
func (rs *ResilientSearcher) selectProxyForTag(ctx context.Context, tag string) string {
if rs.proxyRegistry == nil {
return ""
}
return rs.proxyRegistry.NextByTagWithContext(ctx, tag)
}
func (rs *ResilientSearcher) reportProxyAttempt(ctx context.Context, proxyURL string, err error) {
if rs.proxyRegistry == nil || proxyURL == "" {
return
}
if err != nil {
// Only degrade proxy health for network-level errors. Captcha pages,
// parser drift, and engine errors do not indicate a faulty proxy.
if IsProxyNetworkError(err) {
rs.proxyRegistry.ReportFailure(ctx, proxyURL)
}
return
}
rs.proxyRegistry.ReportSuccess(ctx, proxyURL)
}
func (rs *ResilientSearcher) selectProxyForQuery(policy ProxyPolicy, q Query, ctx context.Context) string {
if policy.Mode != ProxyModeTagPool {
return ""
}
if q.ProxyOverride == "" {
if global := strings.TrimSpace(rs.proxyCfg.Proxies.Global); global != "" {
return global
}
}
return rs.selectProxyForTag(ctx, policy.Tag)
}
var ErrAllEnginesFailed = fmt.Errorf("all search engines failed")