manager.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. package websearch
  2. import (
  3. "context"
  4. "crypto/tls"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "math/rand"
  9. "net"
  10. "net/http"
  11. "net/url"
  12. "sort"
  13. "strings"
  14. "sync"
  15. "time"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
  17. "github.com/redis/go-redis/v9"
  18. )
  19. // ProviderConfig holds the configuration for a single search provider.
  20. type ProviderConfig struct {
  21. Type string `json:"type"` // ProviderTypeBrave | ProviderTypeTavily
  22. APIKey string `json:"api_key"` // secret
  23. QuotaLimit int64 `json:"quota_limit"` // 0 = unlimited
  24. SubscribedAt *int64 `json:"subscribed_at,omitempty"` // subscription start (unix seconds); quota resets monthly from this date
  25. ProxyURL string `json:"-"` // resolved proxy URL (not persisted)
  26. ProxyID int64 `json:"-"` // resolved proxy ID for unavailability tracking
  27. ExpiresAt *int64 `json:"expires_at,omitempty"` // optional expiration (unix seconds)
  28. }
  29. // Manager selects providers by quota-weighted load balancing and tracks quota via Redis.
  30. type Manager struct {
  31. configs []ProviderConfig
  32. redis *redis.Client
  33. clientMu sync.Mutex
  34. clientCache map[string]*http.Client
  35. }
  36. // Timeout constants for proxy and search operations.
  37. const (
  38. proxyDialTimeout = 3 * time.Second // proxy TCP connection timeout
  39. proxyTLSTimeout = 3 * time.Second // TLS handshake timeout
  40. searchDataTimeout = 60 * time.Second // response data transfer timeout
  41. searchRequestTimeout = searchDataTimeout + proxyDialTimeout
  42. quotaKeyPrefix = "websearch:quota:"
  43. proxyUnavailableKey = "websearch:proxy_unavailable:%d"
  44. proxyUnavailableTTL = 5 * time.Minute
  45. quotaTTLBuffer = 24 * time.Hour
  46. defaultQuotaTTL = 31*24*time.Hour + quotaTTLBuffer // fallback when no subscription date
  47. maxCachedClients = 100
  48. )
  49. // ErrProxyUnavailable indicates the search failed due to a proxy connectivity issue.
  50. // Callers may use this to trigger account switching instead of direct fallback.
  51. var ErrProxyUnavailable = errors.New("websearch: proxy unavailable")
  52. // quotaIncrScript atomically increments the counter and sets TTL on first creation.
  53. var quotaIncrScript = redis.NewScript(`
  54. local val = redis.call('INCR', KEYS[1])
  55. if val == 1 then
  56. redis.call('EXPIRE', KEYS[1], ARGV[1])
  57. else
  58. local ttl = redis.call('TTL', KEYS[1])
  59. if ttl == -1 then
  60. redis.call('EXPIRE', KEYS[1], ARGV[1])
  61. end
  62. end
  63. return val
  64. `)
  65. // NewManager creates a Manager with the given provider configs and Redis client.
  66. // Provider order is preserved as-is; selectByQuotaWeight handles load balancing.
  67. func NewManager(configs []ProviderConfig, redisClient *redis.Client) *Manager {
  68. copied := make([]ProviderConfig, len(configs))
  69. copy(copied, configs)
  70. return &Manager{
  71. configs: copied,
  72. redis: redisClient,
  73. clientCache: make(map[string]*http.Client),
  74. }
  75. }
  76. // SearchWithBestProvider selects a provider using quota-weighted load balancing,
  77. // reserves quota, executes the search, and rolls back quota on failure.
  78. // If the search fails due to a proxy error, the proxy is marked unavailable for 5 minutes.
  79. func (m *Manager) SearchWithBestProvider(ctx context.Context, req SearchRequest) (*SearchResponse, string, error) {
  80. if strings.TrimSpace(req.Query) == "" {
  81. return nil, "", fmt.Errorf("websearch: empty search query")
  82. }
  83. candidates := m.filterAvailableProviders(ctx, req.ProxyURL)
  84. if len(candidates) == 0 {
  85. return nil, "", fmt.Errorf("websearch: no available provider (all exhausted, expired, or proxy unavailable)")
  86. }
  87. selected := m.selectByQuotaWeight(ctx, candidates)
  88. for _, cfg := range selected {
  89. allowed, incremented := m.tryReserveQuota(ctx, cfg)
  90. if !allowed {
  91. continue
  92. }
  93. resp, err := m.executeSearch(ctx, cfg, req)
  94. if err != nil {
  95. if incremented {
  96. m.rollbackQuota(ctx, cfg)
  97. }
  98. if isProxyError(err) {
  99. m.markProxyUnavailable(ctx, cfg, req.ProxyURL)
  100. if req.ProxyURL != "" {
  101. // Account-level proxy is shared by all providers — no point
  102. // trying others with the same broken proxy; signal account switch.
  103. slog.Warn("websearch: account proxy error, aborting failover",
  104. "provider", cfg.Type, "error", err)
  105. return nil, "", fmt.Errorf("%w: %s", ErrProxyUnavailable, err.Error())
  106. }
  107. // Provider-specific proxy failed — try the next provider which
  108. // may use a different (or no) proxy.
  109. slog.Warn("websearch: provider proxy error, trying next provider",
  110. "provider", cfg.Type, "error", err)
  111. continue
  112. }
  113. slog.Warn("websearch: provider search failed",
  114. "provider", cfg.Type, "error", err)
  115. continue
  116. }
  117. return resp, cfg.Type, nil
  118. }
  119. return nil, "", fmt.Errorf("websearch: no available provider (all exhausted or failed)")
  120. }
  121. // filterAvailableProviders returns providers that have API keys, are not expired,
  122. // and whose proxies are not marked unavailable.
  123. func (m *Manager) filterAvailableProviders(ctx context.Context, accountProxyURL string) []ProviderConfig {
  124. var out []ProviderConfig
  125. for _, cfg := range m.configs {
  126. if !m.isProviderAvailable(cfg) {
  127. continue
  128. }
  129. proxyID := resolveProxyID(cfg, accountProxyURL)
  130. if proxyID > 0 && !m.isProxyAvailable(ctx, proxyID) {
  131. slog.Debug("websearch: proxy marked unavailable, skipping",
  132. "provider", cfg.Type, "proxy_id", proxyID)
  133. continue
  134. }
  135. out = append(out, cfg)
  136. }
  137. return out
  138. }
  139. // weighted is a provider candidate with computed quota weight.
  140. type weighted struct {
  141. cfg ProviderConfig
  142. weight int64
  143. }
  144. // selectByQuotaWeight orders candidates by remaining quota weight.
  145. // Providers with quota_limit=0 (no limit set) get weight 0 and are placed last.
  146. // Among providers with quota, higher remaining quota = higher priority.
  147. func (m *Manager) selectByQuotaWeight(ctx context.Context, candidates []ProviderConfig) []ProviderConfig {
  148. items := m.computeWeights(ctx, candidates)
  149. withQuota, withoutQuota := partitionByQuota(items)
  150. sortByStableRandomWeight(withQuota)
  151. return mergeWeightedResults(withQuota, withoutQuota, len(candidates))
  152. }
  153. func (m *Manager) computeWeights(ctx context.Context, candidates []ProviderConfig) []weighted {
  154. items := make([]weighted, 0, len(candidates))
  155. for _, cfg := range candidates {
  156. w := int64(0)
  157. if cfg.QuotaLimit > 0 {
  158. used, _ := m.GetUsage(ctx, cfg.Type)
  159. if remaining := cfg.QuotaLimit - used; remaining > 0 {
  160. w = remaining
  161. }
  162. }
  163. items = append(items, weighted{cfg: cfg, weight: w})
  164. }
  165. return items
  166. }
  167. func partitionByQuota(items []weighted) (withQuota, withoutQuota []weighted) {
  168. for _, item := range items {
  169. if item.weight > 0 {
  170. withQuota = append(withQuota, item)
  171. } else {
  172. withoutQuota = append(withoutQuota, item)
  173. }
  174. }
  175. return
  176. }
  177. // sortByStableRandomWeight assigns a fixed random factor to each item before sorting,
  178. // ensuring deterministic sort behavior (transitivity) within a single call.
  179. func sortByStableRandomWeight(items []weighted) {
  180. if len(items) <= 1 {
  181. return
  182. }
  183. type entry struct {
  184. item weighted
  185. factor float64
  186. }
  187. entries := make([]entry, len(items))
  188. for i, item := range items {
  189. entries[i] = entry{item: item, factor: float64(item.weight) * (0.5 + rand.Float64())}
  190. }
  191. sort.Slice(entries, func(i, j int) bool {
  192. return entries[i].factor > entries[j].factor
  193. })
  194. for i, e := range entries {
  195. items[i] = e.item
  196. }
  197. }
  198. func mergeWeightedResults(withQuota, withoutQuota []weighted, capacity int) []ProviderConfig {
  199. result := make([]ProviderConfig, 0, capacity)
  200. for _, item := range withQuota {
  201. result = append(result, item.cfg)
  202. }
  203. for _, item := range withoutQuota {
  204. result = append(result, item.cfg)
  205. }
  206. return result
  207. }
  208. func (m *Manager) isProviderAvailable(cfg ProviderConfig) bool {
  209. if cfg.APIKey == "" {
  210. return false
  211. }
  212. if cfg.ExpiresAt != nil && time.Now().Unix() > *cfg.ExpiresAt {
  213. slog.Info("websearch: provider expired, skipping",
  214. "provider", cfg.Type, "expires_at", *cfg.ExpiresAt)
  215. return false
  216. }
  217. return true
  218. }
  219. // --- Proxy availability tracking ---
  220. // markProxyUnavailable marks the effective proxy as unavailable for proxyUnavailableTTL.
  221. func (m *Manager) markProxyUnavailable(ctx context.Context, cfg ProviderConfig, accountProxyURL string) {
  222. proxyID := resolveProxyID(cfg, accountProxyURL)
  223. if proxyID <= 0 || m.redis == nil {
  224. return
  225. }
  226. key := fmt.Sprintf(proxyUnavailableKey, proxyID)
  227. if err := m.redis.Set(ctx, key, "1", proxyUnavailableTTL).Err(); err != nil {
  228. slog.Warn("websearch: failed to mark proxy unavailable",
  229. "proxy_id", proxyID, "error", err)
  230. }
  231. }
  232. // isProxyAvailable checks whether a proxy is currently marked as unavailable.
  233. func (m *Manager) isProxyAvailable(ctx context.Context, proxyID int64) bool {
  234. if m.redis == nil || proxyID <= 0 {
  235. return true
  236. }
  237. key := fmt.Sprintf(proxyUnavailableKey, proxyID)
  238. val, err := m.redis.Get(ctx, key).Result()
  239. if err != nil {
  240. return true // Redis error → assume available
  241. }
  242. return val == ""
  243. }
  244. // resolveProxyID determines the effective proxy ID for a provider+account combination.
  245. func resolveProxyID(cfg ProviderConfig, accountProxyURL string) int64 {
  246. if accountProxyURL != "" {
  247. return 0 // account proxy has no ID in provider config
  248. }
  249. return cfg.ProxyID
  250. }
  251. // isProxyError checks whether the error is likely caused by proxy or network connectivity
  252. // (as opposed to an API-level error from the search provider).
  253. func isProxyError(err error) bool {
  254. if err == nil {
  255. return false
  256. }
  257. // Network-level errors (timeout, connection refused, DNS failure)
  258. var netErr net.Error
  259. if errors.As(err, &netErr) {
  260. return true
  261. }
  262. var opErr *net.OpError
  263. if errors.As(err, &opErr) {
  264. return true
  265. }
  266. // TLS handshake failures (often caused by proxy intercepting/blocking)
  267. var tlsErr *tls.RecordHeaderError
  268. if errors.As(err, &tlsErr) {
  269. return true
  270. }
  271. // String-based detection for wrapped errors
  272. msg := strings.ToLower(err.Error())
  273. return strings.Contains(msg, "proxy") ||
  274. strings.Contains(msg, "socks") ||
  275. strings.Contains(msg, "connection refused") ||
  276. strings.Contains(msg, "no such host") ||
  277. strings.Contains(msg, "i/o timeout") ||
  278. strings.Contains(msg, "tls handshake") ||
  279. strings.Contains(msg, "certificate")
  280. }
  281. // --- Quota management ---
  282. func (m *Manager) tryReserveQuota(ctx context.Context, cfg ProviderConfig) (bool, bool) {
  283. if cfg.QuotaLimit <= 0 {
  284. return true, false
  285. }
  286. if m.redis == nil {
  287. slog.Warn("websearch: Redis unavailable, quota check skipped", "provider", cfg.Type)
  288. return true, false
  289. }
  290. key := quotaRedisKey(cfg.Type)
  291. ttlSec := int(quotaTTLFromSubscription(cfg.SubscribedAt).Seconds())
  292. newVal, err := quotaIncrScript.Run(ctx, m.redis, []string{key}, ttlSec).Int64()
  293. if err != nil {
  294. slog.Warn("websearch: quota Lua INCR failed, allowing request",
  295. "provider", cfg.Type, "error", err)
  296. return true, false
  297. }
  298. if newVal > cfg.QuotaLimit {
  299. if decrErr := m.redis.Decr(ctx, key).Err(); decrErr != nil {
  300. slog.Warn("websearch: quota over-limit DECR failed",
  301. "provider", cfg.Type, "error", decrErr)
  302. }
  303. slog.Info("websearch: provider quota exhausted",
  304. "provider", cfg.Type, "used", newVal, "limit", cfg.QuotaLimit)
  305. return false, false
  306. }
  307. return true, true
  308. }
  309. func (m *Manager) rollbackQuota(ctx context.Context, cfg ProviderConfig) {
  310. if cfg.QuotaLimit <= 0 || m.redis == nil {
  311. return
  312. }
  313. key := quotaRedisKey(cfg.Type)
  314. if err := m.redis.Decr(ctx, key).Err(); err != nil {
  315. slog.Warn("websearch: quota rollback DECR failed",
  316. "provider", cfg.Type, "error", err)
  317. }
  318. }
  319. // --- Search execution ---
  320. // TestSearch executes a search using the first available provider without reserving quota.
  321. // Intended for admin test functionality only.
  322. func (m *Manager) TestSearch(ctx context.Context, req SearchRequest) (*SearchResponse, string, error) {
  323. if strings.TrimSpace(req.Query) == "" {
  324. return nil, "", fmt.Errorf("websearch: empty search query")
  325. }
  326. for _, cfg := range m.configs {
  327. if !m.isProviderAvailable(cfg) {
  328. continue
  329. }
  330. resp, err := m.executeSearch(ctx, cfg, req)
  331. if err != nil {
  332. continue
  333. }
  334. return resp, cfg.Type, nil
  335. }
  336. return nil, "", fmt.Errorf("websearch: no available provider")
  337. }
  338. func (m *Manager) executeSearch(ctx context.Context, cfg ProviderConfig, req SearchRequest) (*SearchResponse, error) {
  339. proxyURL := cfg.ProxyURL
  340. if req.ProxyURL != "" {
  341. proxyURL = req.ProxyURL
  342. }
  343. client, err := m.getOrCreateHTTPClient(proxyURL)
  344. if err != nil {
  345. return nil, fmt.Errorf("websearch: %w", err)
  346. }
  347. provider := m.buildProvider(cfg, client)
  348. return provider.Search(ctx, req)
  349. }
  350. // --- HTTP client cache ---
  351. func (m *Manager) getOrCreateHTTPClient(proxyURL string) (*http.Client, error) {
  352. m.clientMu.Lock()
  353. defer m.clientMu.Unlock()
  354. if c, ok := m.clientCache[proxyURL]; ok {
  355. return c, nil
  356. }
  357. if len(m.clientCache) >= maxCachedClients {
  358. m.clientCache = make(map[string]*http.Client)
  359. }
  360. c, err := newHTTPClient(proxyURL)
  361. if err != nil {
  362. return nil, err
  363. }
  364. m.clientCache[proxyURL] = c
  365. return c, nil
  366. }
  367. // newHTTPClient creates an HTTP client with proper timeout settings.
  368. // Uses proxyutil.ConfigureTransportProxy for unified proxy protocol support
  369. // (HTTP/HTTPS/SOCKS5/SOCKS5H).
  370. // Returns error if proxyURL is invalid — never falls back to direct connection.
  371. func newHTTPClient(proxyURL string) (*http.Client, error) {
  372. transport := &http.Transport{
  373. TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
  374. DialContext: (&net.Dialer{Timeout: proxyDialTimeout}).DialContext,
  375. TLSHandshakeTimeout: proxyTLSTimeout,
  376. ResponseHeaderTimeout: searchDataTimeout,
  377. }
  378. if proxyURL != "" {
  379. parsed, err := url.Parse(proxyURL)
  380. if err != nil {
  381. return nil, fmt.Errorf("invalid proxy URL %q: %w", proxyURL, err)
  382. }
  383. if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil {
  384. return nil, fmt.Errorf("configure proxy: %w", err)
  385. }
  386. }
  387. return &http.Client{Transport: transport, Timeout: searchRequestTimeout}, nil
  388. }
  389. // GetUsage returns the current usage count for the given provider.
  390. func (m *Manager) GetUsage(ctx context.Context, providerType string) (int64, error) {
  391. if m.redis == nil {
  392. return 0, nil
  393. }
  394. key := quotaRedisKey(providerType)
  395. val, err := m.redis.Get(ctx, key).Int64()
  396. if err == redis.Nil {
  397. return 0, nil
  398. }
  399. return val, err
  400. }
  401. // GetAllUsage returns usage for every configured provider.
  402. func (m *Manager) GetAllUsage(ctx context.Context) map[string]int64 {
  403. result := make(map[string]int64, len(m.configs))
  404. for _, cfg := range m.configs {
  405. used, _ := m.GetUsage(ctx, cfg.Type)
  406. result[cfg.Type] = used
  407. }
  408. return result
  409. }
  410. // ResetUsage deletes the Redis quota key for the given provider, resetting usage to 0.
  411. func (m *Manager) ResetUsage(ctx context.Context, providerType string) error {
  412. if m.redis == nil {
  413. return nil
  414. }
  415. key := quotaRedisKey(providerType)
  416. return m.redis.Del(ctx, key).Err()
  417. }
  418. // --- Provider factory ---
  419. func (m *Manager) buildProvider(cfg ProviderConfig, client *http.Client) Provider {
  420. switch cfg.Type {
  421. case braveProviderName:
  422. return NewBraveProvider(cfg.APIKey, client)
  423. case tavilyProviderName:
  424. return NewTavilyProvider(cfg.APIKey, client)
  425. default:
  426. slog.Warn("websearch: unknown provider type, falling back to brave",
  427. "type", cfg.Type)
  428. return NewBraveProvider(cfg.APIKey, client)
  429. }
  430. }
  431. // --- Redis key helpers ---
  432. func quotaRedisKey(providerType string) string {
  433. return quotaKeyPrefix + providerType
  434. }
  435. // quotaTTLFromSubscription calculates the TTL for the quota counter based on
  436. // the provider's subscription start date. Quota resets monthly from that date.
  437. // When the Redis key expires naturally, the next INCR creates a fresh counter (lazy refresh).
  438. func quotaTTLFromSubscription(subscribedAt *int64) time.Duration {
  439. if subscribedAt == nil || *subscribedAt == 0 {
  440. return defaultQuotaTTL
  441. }
  442. next := nextMonthlyReset(time.Unix(*subscribedAt, 0).UTC())
  443. ttl := time.Until(next) + quotaTTLBuffer
  444. if ttl <= quotaTTLBuffer {
  445. // Already past the reset — next cycle
  446. ttl = defaultQuotaTTL
  447. }
  448. return ttl
  449. }
  450. // nextMonthlyReset returns the next monthly reset time based on the subscription start date.
  451. // E.g., subscribed on Jan 15 → resets on Feb 15, Mar 15, etc.
  452. // Handles day-of-month overflow: Jan 31 → Feb 28 (not Mar 3).
  453. func nextMonthlyReset(subscribedAt time.Time) time.Time {
  454. now := time.Now().UTC()
  455. if subscribedAt.IsZero() {
  456. return now.AddDate(0, 1, 0)
  457. }
  458. months := (now.Year()-subscribedAt.Year())*12 + int(now.Month()-subscribedAt.Month())
  459. if months < 0 {
  460. months = 0
  461. }
  462. candidate := addMonthsClamped(subscribedAt, months)
  463. if candidate.After(now) {
  464. return candidate
  465. }
  466. return addMonthsClamped(subscribedAt, months+1)
  467. }
  468. // addMonthsClamped adds N months to a date, clamping the day to the last day of the target month.
  469. // E.g., Jan 31 + 1 month = Feb 28 (not Mar 3).
  470. func addMonthsClamped(t time.Time, months int) time.Time {
  471. y, m, d := t.Date()
  472. targetMonth := time.Month(int(m) + months)
  473. targetYear := y + int(targetMonth-1)/12
  474. targetMonth = (targetMonth-1)%12 + 1
  475. // Last day of the target month
  476. lastDay := time.Date(targetYear, targetMonth+1, 0, 0, 0, 0, 0, time.UTC).Day()
  477. if d > lastDay {
  478. d = lastDay
  479. }
  480. return time.Date(targetYear, targetMonth, d, 0, 0, 0, 0, time.UTC)
  481. }