fix(websearch): improve isProxyError detection and add manager tests

- Add TLS error detection to isProxyError (RecordHeaderError, handshake)
- Case-insensitive error string matching
- Add 19 unit tests for: isProviderAvailable, resolveProxyID,
  isProxyError, isProxyAvailable, selectByQuotaWeight, newHTTPClient
This commit is contained in:
erio
2026-04-12 02:11:50 +08:00
parent 499159870c
commit 60b0fa81ec
2 changed files with 147 additions and 5 deletions

View File

@@ -253,25 +253,35 @@ func resolveProxyID(cfg ProviderConfig, accountProxyURL string) int64 {
return cfg.ProxyID return cfg.ProxyID
} }
// isProxyError checks whether the error is likely caused by proxy connectivity. // isProxyError checks whether the error is likely caused by proxy or network connectivity
// (as opposed to an API-level error from the search provider).
func isProxyError(err error) bool { func isProxyError(err error) bool {
if err == nil { if err == nil {
return false return false
} }
// Network-level errors (timeout, connection refused, DNS failure)
var netErr net.Error var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() { if errors.As(err, &netErr) {
return true return true
} }
var opErr *net.OpError var opErr *net.OpError
if errors.As(err, &opErr) { if errors.As(err, &opErr) {
return true return true
} }
msg := err.Error() // TLS handshake failures (often caused by proxy intercepting/blocking)
var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) {
return true
}
// String-based detection for wrapped errors
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "proxy") || return strings.Contains(msg, "proxy") ||
strings.Contains(msg, "SOCKS") || strings.Contains(msg, "socks") ||
strings.Contains(msg, "connection refused") || strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "no such host") || strings.Contains(msg, "no such host") ||
strings.Contains(msg, "i/o timeout") strings.Contains(msg, "i/o timeout") ||
strings.Contains(msg, "tls handshake") ||
strings.Contains(msg, "certificate")
} }
// --- Quota management --- // --- Quota management ---

View File

@@ -3,6 +3,7 @@ package websearch
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -147,3 +148,134 @@ func TestQuotaRedisKey_Format(t *testing.T) {
key := quotaRedisKey("brave", QuotaRefreshDaily) key := quotaRedisKey("brave", QuotaRefreshDaily)
require.Contains(t, key, "websearch:quota:brave:") require.Contains(t, key, "websearch:quota:brave:")
} }
// --- isProviderAvailable ---
func TestIsProviderAvailable_EmptyAPIKey(t *testing.T) {
m := NewManager(nil, nil)
require.False(t, m.isProviderAvailable(ProviderConfig{APIKey: ""}))
}
func TestIsProviderAvailable_Expired(t *testing.T) {
m := NewManager(nil, nil)
past := time.Now().Add(-1 * time.Hour).Unix()
require.False(t, m.isProviderAvailable(ProviderConfig{APIKey: "k", ExpiresAt: &past}))
}
func TestIsProviderAvailable_Valid(t *testing.T) {
m := NewManager(nil, nil)
future := time.Now().Add(1 * time.Hour).Unix()
require.True(t, m.isProviderAvailable(ProviderConfig{APIKey: "k", ExpiresAt: &future}))
require.True(t, m.isProviderAvailable(ProviderConfig{APIKey: "k"})) // no expiry
}
// --- resolveProxyID ---
func TestResolveProxyID_AccountProxyOverrides(t *testing.T) {
cfg := ProviderConfig{ProxyID: 42}
// account proxy present → return 0 (account proxy has no config-level ID)
require.Equal(t, int64(0), resolveProxyID(cfg, "http://account-proxy:8080"))
// no account proxy → return provider's proxy ID
require.Equal(t, int64(42), resolveProxyID(cfg, ""))
}
// --- isProxyError ---
func TestIsProxyError_Nil(t *testing.T) {
require.False(t, isProxyError(nil))
}
func TestIsProxyError_ConnectionRefused(t *testing.T) {
err := fmt.Errorf("dial tcp: connection refused")
require.True(t, isProxyError(err))
}
func TestIsProxyError_Timeout(t *testing.T) {
err := fmt.Errorf("i/o timeout while connecting to proxy")
require.True(t, isProxyError(err))
}
func TestIsProxyError_SOCKS(t *testing.T) {
err := fmt.Errorf("socks connect failed")
require.True(t, isProxyError(err))
}
func TestIsProxyError_TLSHandshake(t *testing.T) {
err := fmt.Errorf("tls handshake timeout")
require.True(t, isProxyError(err))
}
func TestIsProxyError_APIError_NotProxy(t *testing.T) {
err := fmt.Errorf("API rate limit exceeded")
require.False(t, isProxyError(err))
}
// --- isProxyAvailable (nil Redis) ---
func TestIsProxyAvailable_NilRedis(t *testing.T) {
m := NewManager(nil, nil)
require.True(t, m.isProxyAvailable(context.Background(), 42))
}
func TestIsProxyAvailable_ZeroID(t *testing.T) {
m := NewManager(nil, nil)
require.True(t, m.isProxyAvailable(context.Background(), 0))
}
// --- selectByQuotaWeight ---
func TestSelectByQuotaWeight_NoQuotaLast(t *testing.T) {
m := NewManager(nil, nil) // nil Redis → GetUsage returns 0
candidates := []ProviderConfig{
{Type: "brave", APIKey: "k1", QuotaLimit: 0}, // no limit → weight 0
{Type: "tavily", APIKey: "k2", QuotaLimit: 100}, // remaining 100
}
result := m.selectByQuotaWeight(context.Background(), candidates)
require.Len(t, result, 2)
// tavily (with quota) should come first
require.Equal(t, "tavily", result[0].Type)
require.Equal(t, "brave", result[1].Type)
}
func TestSelectByQuotaWeight_AllNoQuota(t *testing.T) {
m := NewManager(nil, nil)
candidates := []ProviderConfig{
{Type: "brave", APIKey: "k1", QuotaLimit: 0},
{Type: "tavily", APIKey: "k2", QuotaLimit: 0},
}
result := m.selectByQuotaWeight(context.Background(), candidates)
require.Len(t, result, 2)
// both have weight 0, original order preserved
}
func TestSelectByQuotaWeight_Empty(t *testing.T) {
m := NewManager(nil, nil)
result := m.selectByQuotaWeight(context.Background(), nil)
require.Empty(t, result)
}
// --- newHTTPClient ---
func TestNewHTTPClient_NoProxy(t *testing.T) {
c, err := newHTTPClient("")
require.NoError(t, err)
require.NotNil(t, c)
}
func TestNewHTTPClient_InvalidProxy(t *testing.T) {
_, err := newHTTPClient("://bad-url")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid proxy URL")
}
func TestNewHTTPClient_ValidHTTPProxy(t *testing.T) {
c, err := newHTTPClient("http://proxy.example.com:8080")
require.NoError(t, err)
require.NotNil(t, c)
}
func TestNewHTTPClient_ValidSOCKS5Proxy(t *testing.T) {
c, err := newHTTPClient("socks5://proxy.example.com:1080")
require.NoError(t, err)
require.NotNil(t, c)
}