fix(web-search): improve SearXNG test connection error messages
Return provider-specific diagnostics when connectivity tests return zero results, including SearXNG unresponsive_engines details instead of a misleading API key hint.
This commit is contained in:
@@ -422,8 +422,9 @@ func (h *WebSearchProviderHandler) doTestSearch(ctx context.Context, providerTyp
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
logger.Warnf(ctx, "[WebSearch][Test] search returned 0 results — API key or configuration may be invalid")
|
||||
return fmt.Errorf("search returned 0 results, please verify your API key and configuration")
|
||||
err := infra_web_search.EmptyTestResultsError(providerType, searchProvider)
|
||||
logger.Warnf(ctx, "[WebSearch][Test] %v", err)
|
||||
return err
|
||||
}
|
||||
logger.Infof(ctx, "[WebSearch][Test] succeeded: type=%s, results=%d", providerType, len(results))
|
||||
return nil
|
||||
|
||||
@@ -28,8 +28,9 @@ const defaultSearxngTimeout = 12 * time.Second
|
||||
// validated with utils.ValidateURLForSSRF; private/loopback hosts must be added
|
||||
// to the SSRF_WHITELIST environment variable.
|
||||
type SearxngProvider struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
client *http.Client
|
||||
baseURL string
|
||||
lastUnresponsive [][]string
|
||||
}
|
||||
|
||||
// ValidateSearxngBaseURL validates a SearXNG instance URL: must be a non-empty,
|
||||
@@ -77,6 +78,15 @@ func NewSearxngProvider(params types.WebSearchProviderParameters) (interfaces.We
|
||||
// Name returns the provider name.
|
||||
func (p *SearxngProvider) Name() string { return "searxng" }
|
||||
|
||||
// EmptyResultDiagnostics explains why the most recent search returned no
|
||||
// usable results. Used by the settings "test connection" flow.
|
||||
func (p *SearxngProvider) EmptyResultDiagnostics() string {
|
||||
if detail := formatUnresponsiveEngines(p.lastUnresponsive); detail != "" {
|
||||
return detail + "; check that upstream search engines can reach the internet"
|
||||
}
|
||||
return "verify the instance URL is reachable and JSON format is enabled in settings.yml"
|
||||
}
|
||||
|
||||
// Search performs a metasearch query against the configured SearXNG instance.
|
||||
// SearXNG must have `search.formats: [json]` enabled in settings.yml.
|
||||
func (p *SearxngProvider) Search(
|
||||
@@ -124,8 +134,10 @@ func (p *SearxngProvider) Search(
|
||||
|
||||
var data searxngResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
p.lastUnresponsive = nil
|
||||
return nil, fmt.Errorf("failed to decode SearXNG response (ensure JSON format is enabled in settings.yml): %w", err)
|
||||
}
|
||||
p.lastUnresponsive = data.UnresponsiveEngines
|
||||
|
||||
results := make([]*types.WebSearchResult, 0, maxResults)
|
||||
for _, r := range data.Results {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -127,3 +128,40 @@ func TestSearxngProvider_Search(t *testing.T) {
|
||||
t.Fatalf("unexpected source: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearxngProvider_Search_EmptyWithUnresponsiveEngines(t *testing.T) {
|
||||
utils.ResetSSRFWhitelistForTest()
|
||||
os.Setenv("SSRF_WHITELIST", "127.0.0.1,localhost")
|
||||
defer func() {
|
||||
os.Unsetenv("SSRF_WHITELIST")
|
||||
utils.ResetSSRFWhitelistForTest()
|
||||
}()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []any{},
|
||||
"unresponsive_engines": [][]string{{"google", "timeout"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
provider, err := NewSearxngProvider(types.WebSearchProviderParameters{BaseURL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSearxngProvider: %v", err)
|
||||
}
|
||||
sp := provider.(*SearxngProvider)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
results, err := sp.Search(ctx, "test", 1, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("expected 0 results, got %d", len(results))
|
||||
}
|
||||
if got := sp.EmptyResultDiagnostics(); !strings.Contains(got, "google (timeout)") {
|
||||
t.Fatalf("EmptyResultDiagnostics() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package web_search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// emptyResultDiagnostics is implemented by providers that can explain an empty
|
||||
// search result set (used by the settings "test connection" flow).
|
||||
type emptyResultDiagnostics interface {
|
||||
EmptyResultDiagnostics() string
|
||||
}
|
||||
|
||||
// EmptyTestResultsError builds a provider-specific message when a connectivity
|
||||
// test search succeeds but returns no usable results.
|
||||
func EmptyTestResultsError(providerType string, provider any) error {
|
||||
detail := ""
|
||||
if dr, ok := provider.(emptyResultDiagnostics); ok {
|
||||
detail = dr.EmptyResultDiagnostics()
|
||||
}
|
||||
|
||||
switch types.WebSearchProviderType(providerType) {
|
||||
case types.WebSearchProviderTypeSearxng:
|
||||
if detail != "" {
|
||||
return fmt.Errorf("searxng returned 0 results: %s", detail)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"searxng returned 0 results; verify the instance URL, JSON format in settings.yml, and upstream search engine connectivity",
|
||||
)
|
||||
case types.WebSearchProviderTypeDuckDuckGo:
|
||||
return fmt.Errorf("duckduckgo returned 0 results; verify network connectivity and proxy settings")
|
||||
default:
|
||||
return fmt.Errorf("search returned 0 results, please verify your API key and configuration")
|
||||
}
|
||||
}
|
||||
|
||||
func formatUnresponsiveEngines(engines [][]string) string {
|
||||
if len(engines) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(engines))
|
||||
for _, e := range engines {
|
||||
if len(e) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(e) == 1 {
|
||||
parts = append(parts, e[0])
|
||||
continue
|
||||
}
|
||||
parts = append(parts, e[0]+" ("+e[1]+")")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "unresponsive engines: " + strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package web_search
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestEmptyTestResultsError_SearxngWithUnresponsiveEngines(t *testing.T) {
|
||||
provider := &SearxngProvider{
|
||||
lastUnresponsive: [][]string{
|
||||
{"google", "timeout"},
|
||||
{"bing", "blocked"},
|
||||
},
|
||||
}
|
||||
err := EmptyTestResultsError(string(types.WebSearchProviderTypeSearxng), provider)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "searxng returned 0 results") {
|
||||
t.Fatalf("unexpected message: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "google (timeout)") || !strings.Contains(msg, "bing (blocked)") {
|
||||
t.Fatalf("expected unresponsive engine details, got: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTestResultsError_SearxngWithoutDiagnostics(t *testing.T) {
|
||||
provider := &SearxngProvider{}
|
||||
err := EmptyTestResultsError(string(types.WebSearchProviderTypeSearxng), provider)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "JSON format is enabled in settings.yml") {
|
||||
t.Fatalf("unexpected message: %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "API key") {
|
||||
t.Fatalf("searxng message must not mention API key: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTestResultsError_DuckDuckGo(t *testing.T) {
|
||||
err := EmptyTestResultsError(string(types.WebSearchProviderTypeDuckDuckGo), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duckduckgo returned 0 results") {
|
||||
t.Fatalf("unexpected message: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTestResultsError_APIKeyProviders(t *testing.T) {
|
||||
err := EmptyTestResultsError(string(types.WebSearchProviderTypeBing), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "API key") {
|
||||
t.Fatalf("bing message should mention API key: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatUnresponsiveEngines(t *testing.T) {
|
||||
got := formatUnresponsiveEngines([][]string{
|
||||
{"google", "timeout"},
|
||||
{"wikipedia"},
|
||||
})
|
||||
want := "unresponsive engines: google (timeout), wikipedia"
|
||||
if got != want {
|
||||
t.Fatalf("formatUnresponsiveEngines() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user