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:
wizardchen
2026-06-24 14:25:10 +08:00
committed by lyingbug
parent 8242b6f298
commit fa8e2bb087
5 changed files with 187 additions and 4 deletions
+3 -2
View File
@@ -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
+14 -2
View File
@@ -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)
}
}