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:
@@ -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 ---
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user