diff --git a/backend/internal/pkg/websearch/manager.go b/backend/internal/pkg/websearch/manager.go index e6334b70..7db3d9a2 100644 --- a/backend/internal/pkg/websearch/manager.go +++ b/backend/internal/pkg/websearch/manager.go @@ -253,25 +253,35 @@ func resolveProxyID(cfg ProviderConfig, accountProxyURL string) int64 { 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 { if err == nil { return false } + // Network-level errors (timeout, connection refused, DNS failure) var netErr net.Error - if errors.As(err, &netErr) && netErr.Timeout() { + if errors.As(err, &netErr) { return true } var opErr *net.OpError if errors.As(err, &opErr) { 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") || - strings.Contains(msg, "SOCKS") || + strings.Contains(msg, "socks") || strings.Contains(msg, "connection refused") || 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 --- diff --git a/backend/internal/pkg/websearch/manager_test.go b/backend/internal/pkg/websearch/manager_test.go index 4387a2ee..d3cd29d6 100644 --- a/backend/internal/pkg/websearch/manager_test.go +++ b/backend/internal/pkg/websearch/manager_test.go @@ -3,6 +3,7 @@ package websearch import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -147,3 +148,134 @@ func TestQuotaRedisKey_Format(t *testing.T) { key := quotaRedisKey("brave", QuotaRefreshDaily) 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) +}