Decision proxy in front of SearXNG with Brave Search API fallback
Go
7
31 commits
updated Sep 17, 2026
Decision proxy in front of SearXNG: speculative execution — starts SearXNG and the configured premium-provider pass concurrently, selects providers via round-robin, invokes premium providers serially within that pass, merges results with URL dedup, and loops through remaining providers until a configurable threshold is met or timeout expires. Same JSON shape as SearXNG, Prometheus /metrics, in-memory LRU cache.
🚀 Works with zero API keys in keyless mode. See docs/keyless.md.
Originally derived from sx; adds HTTP server, per-engine circuit breaker, Prometheus metrics, cache, and Docker packaging.
# 1. Clone
git clone https://github.com/Ghilteras/searxng-gateway.git
cd searxng-gateway
# 2. (Optional) Create .env with API keys — or skip for keyless mode
echo 'BRAVE_API_KEY=your_key' > .env
echo 'SERPER_API_KEY=your_key' >> .env
# 3. Start
docker compose -f docker-compose.example.yml up -d
# 4. Test
curl 'http://localhost:8080/search?q=hello+world&format=json'
curl 'http://localhost:8080/metrics'
Client ───▶ searxng-gateway (:8080) ───▶ SearXNG (Tier 1 free engines)
│ │
│ ┌─────────────────────┤
│ │ Speculative execution│
│ │ (parallel round-robin)│
│ └─────────────────────┤
│ ├── Serper (Google via API)
│ ├── Bing, Wikipedia, GitHub...
│ └── Circuit breaker per engine
│
└──▶ Tier 2 hot path (T1_PREMIUM_COUNT providers)
│ ├── Brave ──┐
│ ├── Exa ├── round-robin alongside SearXNG;
│ ├── Jina │ with SearXNG. Dedup URL.
│ └── Tavily ─┘
│
└──▶ Fallback loop (if merged < SUFFICIENT_MIN_RESULTS)
Round-robin through remaining Tier 2 providers
until threshold, exhaustion, or FALLBACK_TIMEOUT
See docs/architecture.md for the full design.
Set FALLBACK_PROVIDERS to a comma-separated list of premium backend names. Each needs its _API_KEY env var. T1_PREMIUM_COUNT controls how many of them run alongside SearXNG in every request (round-robin selection); the rest are used in the fallback loop when the merged result count is below SUFFICIENT_MIN_RESULTS.
| Provider | Env var | Free tier | Production |
|---|---|---|---|
| Brave | BRAVE_API_KEY | $5 credit (1,000/mo) | ✅ Yes |
| Exa | EXA_API_KEY | $20 + $10/mo (~2,800 searches) | ✅ Yes |
| Jina | JINA_API_KEY | 10M tokens, 500 RPM | ✅ Yes |
| Tavily | TAVILY_API_KEY | 1,000 credits/mo | ✅ Yes |
Example:
FALLBACK_PROVIDERS=brave,exa,jina
BRAVE_API_KEY=xxx
EXA_API_KEY=xxx
JINA_API_KEY=xxx
Keyless mode (no API keys) works out of the box using SearXNG's free engines (Bing, Wikipedia, GitHub, etc.). Premium providers require their respective API keys.
T1_PREMIUM_COUNT premium providers are selected via atomic round-robin and invoked in the hot path while SearXNG runs concurrently; premium calls are serial within the pass. Results are merged and deduplicated by URL.SUFFICIENT_MIN_RESULTS, remaining Tier 2 providers are tried via round-robin until threshold, exhaustion, or FALLBACK_TIMEOUT_SECONDS.searxng_gateway_examples/searxng/ with engine selection, suspended_times tuning, custom User-Agent, and custom Python engines (Serper, Mojeek)engine_results_total tracks premium API usage so you can alert before hitting quota limitsThe gateway exposes Prometheus metrics at :8080/metrics.

A reference dashboard is included at examples/grafana/searxng-gateway-dashboard.json. Import it into Grafana, select your Prometheus datasource, and you'll see:

Each engine gets its own row. 🟢 Closed → 🟡 Half-Open → 🔴 Open. When an engine returns 4xx, the circuit opens for 5 minutes, then auto-recovers.

Each trip means the gateway caught a 4xx and opened the circuit before the engine could degrade further queries. Colored by reason: rate_limited, access_denied, captcha.

After 5 minutes of cooldown, the gateway probes the engine. If it responds, the circuit closes and a recovery is recorded.

Repeated queries are served from the in-memory LRU cache (1000 entries, 1h TTL). "Cache hit rate" is the percentage of requests answered from cache (100 * rate(cache_hit) / rate(total)); "Cache size" tracks the current entry count. A low hit rate means every query is hitting SearXNG and premium providers — tune CACHE_SIZE/CACHE_TTL_SECONDS accordingly.
Example Prometheus alert rules (vmalert/Mimir compatible):
groups:
- name: searxng-gateway
rules:
- alert: SearxngCBStuckOpen
expr: searxng_gateway_circuit_breaker_state == 2
for: 5m
annotations:
summary: "Circuit breaker stuck open for engine {{ $labels.engine }}"
- alert: SearxngRetryExhausted
expr: rate(searxng_gateway_retry_exhausted_total[5m]) > 0.01
for: 5m
annotations:
summary: "Retry exhaustion rate elevated"
- alert: FallbackBillSpike
expr: rate(searxng_gateway_engine_results_total[1h]) * 3600 > 100
for: 10m
annotations:
summary: "Fallback API usage > 100 calls/hour — check billing"
GET /search?q=<query>&format=json — proxy endpointGET /healthz — livenessGET /metrics — Prometheus exposition| Var | Default | Required | Description |
|---|---|---|---|
LISTEN_ADDR | :8080 | no | HTTP listen address |
SEARXNG_BACKEND_URL | http://searxng-primary:8080 | no | SearXNG instance URL |
FALLBACK_PROVIDERS | brave | no | Comma-separated list of premium provider names |
BRAVE_API_KEY | — | no | Brave Search API key |
EXA_API_KEY | — | no | Exa Search API key |
JINA_API_KEY | — | no | Jina Search API key |
TAVILY_API_KEY | — | no | Tavily Search API key |
SUFFICIENT_MIN_RESULTS | 1 | no | Target merged result count; loop stops when reached (recommend 10 with premiums) |
T1_PREMIUM_COUNT | 0 | no | Number of premium providers to call in the hot path while SearXNG runs (0 = none; premium calls are serial within the pass) |
FALLBACK_TIMEOUT_SECONDS | 30 | no | Maximum time for speculative execution + fallback loop |
SEARXNG_TIMEOUT_SECONDS | 25 | no | Per-request timeout for SearXNG |
SEARXNG_FAIL_THRESHOLD | 6 | no | Consecutive SearXNG failures before cooldown |
SEARXNG_FAIL_COOLDOWN_SECONDS | 180 | no | Cooldown duration for SearXNG (seconds) |
BRAVE_FAIL_THRESHOLD | 3 | no | Consecutive Brave failures before cooldown |
BRAVE_FAIL_COOLDOWN_SECONDS | 300 | no | Cooldown duration for Brave (seconds) |
BRAVE_TIMEOUT_SECONDS | 15 | no | Per-request timeout for Brave API |
CACHE_SIZE | 1000 | no | LRU cache entries (in-memory) |
CACHE_TTL_SECONDS | 3600 | no | Cache entry TTL (seconds) |
LOG_LEVEL | info | no | Log level (debug, info, warn, error) |
METRICS_PATH | /metrics | no | Prometheus metrics endpoint path |
Implement the SearchBackend interface in backends/:
type MyProvider struct { APIKey string; Timeout time.Duration }
func (m *MyProvider) Name() string { return "myprovider" }
func (m *MyProvider) IsAvailable() bool { return m.APIKey != "" }
func (m *MyProvider) Search(opts SearchOptions) ([]SearchResult, error) { /* ... */ }
Then add a case to backends/factory.go and set MYPROVIDER_API_KEY in the environment. The rest (registry, fallback chain, circuit breaker) is automatic.
docker-compose.example.yml — SearXNG + gateway, one commandexamples/searxng/ — reference SearXNG config with multi-tier engine postureexamples/searxng-engines/ — custom SearXNG engines (Serper, Mojeek API)docs/architecture.md — design decisions, circuit breaker, metricsdocs/keyless.md — how to run without any API keysThe image is built and pushed automatically by GitHub Actions on every push to main and on v* tags: see .github/workflows/build.yml (docker/build-push-action@v6, platforms linux/amd64,linux/arm64, gha cache, push to GHCR). No local multi-arch build needed — the local multiarch buildx builder was removed from the homelab (2026-08-04).
MIT — see LICENSE.
31 commits
Go
98.8%
Decision proxy in front of SearXNG with Brave Search API fallback
Go
7
31 commits
updated Sep 17, 2026
Decision proxy in front of SearXNG: speculative execution — starts SearXNG and the configured premium-provider pass concurrently, selects providers via round-robin, invokes premium providers serially within that pass, merges results with URL dedup, and loops through remaining providers until a configurable threshold is met or timeout expires. Same JSON shape as SearXNG, Prometheus /metrics, in-memory LRU cache.
🚀 Works with zero API keys in keyless mode. See docs/keyless.md.
Originally derived from sx; adds HTTP server, per-engine circuit breaker, Prometheus metrics, cache, and Docker packaging.
# 1. Clone
git clone https://github.com/Ghilteras/searxng-gateway.git
cd searxng-gateway
# 2. (Optional) Create .env with API keys — or skip for keyless mode
echo 'BRAVE_API_KEY=your_key' > .env
echo 'SERPER_API_KEY=your_key' >> .env
# 3. Start
docker compose -f docker-compose.example.yml up -d
# 4. Test
curl 'http://localhost:8080/search?q=hello+world&format=json'
curl 'http://localhost:8080/metrics'
Client ───▶ searxng-gateway (:8080) ───▶ SearXNG (Tier 1 free engines)
│ │
│ ┌─────────────────────┤
│ │ Speculative execution│
│ │ (parallel round-robin)│
│ └─────────────────────┤
│ ├── Serper (Google via API)
│ ├── Bing, Wikipedia, GitHub...
│ └── Circuit breaker per engine
│
└──▶ Tier 2 hot path (T1_PREMIUM_COUNT providers)
│ ├── Brave ──┐
│ ├── Exa ├── round-robin alongside SearXNG;
│ ├── Jina │ with SearXNG. Dedup URL.
│ └── Tavily ─┘
│
└──▶ Fallback loop (if merged < SUFFICIENT_MIN_RESULTS)
Round-robin through remaining Tier 2 providers
until threshold, exhaustion, or FALLBACK_TIMEOUT
See docs/architecture.md for the full design.
Set FALLBACK_PROVIDERS to a comma-separated list of premium backend names. Each needs its _API_KEY env var. T1_PREMIUM_COUNT controls how many of them run alongside SearXNG in every request (round-robin selection); the rest are used in the fallback loop when the merged result count is below SUFFICIENT_MIN_RESULTS.
| Provider | Env var | Free tier | Production |
|---|---|---|---|
| Brave | BRAVE_API_KEY | $5 credit (1,000/mo) | ✅ Yes |
| Exa | EXA_API_KEY | $20 + $10/mo (~2,800 searches) | ✅ Yes |
| Jina | JINA_API_KEY | 10M tokens, 500 RPM | ✅ Yes |
| Tavily | TAVILY_API_KEY | 1,000 credits/mo | ✅ Yes |
Example:
FALLBACK_PROVIDERS=brave,exa,jina
BRAVE_API_KEY=xxx
EXA_API_KEY=xxx
JINA_API_KEY=xxx
Keyless mode (no API keys) works out of the box using SearXNG's free engines (Bing, Wikipedia, GitHub, etc.). Premium providers require their respective API keys.
T1_PREMIUM_COUNT premium providers are selected via atomic round-robin and invoked in the hot path while SearXNG runs concurrently; premium calls are serial within the pass. Results are merged and deduplicated by URL.SUFFICIENT_MIN_RESULTS, remaining Tier 2 providers are tried via round-robin until threshold, exhaustion, or FALLBACK_TIMEOUT_SECONDS.searxng_gateway_examples/searxng/ with engine selection, suspended_times tuning, custom User-Agent, and custom Python engines (Serper, Mojeek)engine_results_total tracks premium API usage so you can alert before hitting quota limitsThe gateway exposes Prometheus metrics at :8080/metrics.

A reference dashboard is included at examples/grafana/searxng-gateway-dashboard.json. Import it into Grafana, select your Prometheus datasource, and you'll see:

Each engine gets its own row. 🟢 Closed → 🟡 Half-Open → 🔴 Open. When an engine returns 4xx, the circuit opens for 5 minutes, then auto-recovers.

Each trip means the gateway caught a 4xx and opened the circuit before the engine could degrade further queries. Colored by reason: rate_limited, access_denied, captcha.

After 5 minutes of cooldown, the gateway probes the engine. If it responds, the circuit closes and a recovery is recorded.

Repeated queries are served from the in-memory LRU cache (1000 entries, 1h TTL). "Cache hit rate" is the percentage of requests answered from cache (100 * rate(cache_hit) / rate(total)); "Cache size" tracks the current entry count. A low hit rate means every query is hitting SearXNG and premium providers — tune CACHE_SIZE/CACHE_TTL_SECONDS accordingly.
Example Prometheus alert rules (vmalert/Mimir compatible):
groups:
- name: searxng-gateway
rules:
- alert: SearxngCBStuckOpen
expr: searxng_gateway_circuit_breaker_state == 2
for: 5m
annotations:
summary: "Circuit breaker stuck open for engine {{ $labels.engine }}"
- alert: SearxngRetryExhausted
expr: rate(searxng_gateway_retry_exhausted_total[5m]) > 0.01
for: 5m
annotations:
summary: "Retry exhaustion rate elevated"
- alert: FallbackBillSpike
expr: rate(searxng_gateway_engine_results_total[1h]) * 3600 > 100
for: 10m
annotations:
summary: "Fallback API usage > 100 calls/hour — check billing"
GET /search?q=<query>&format=json — proxy endpointGET /healthz — livenessGET /metrics — Prometheus exposition| Var | Default | Required | Description |
|---|---|---|---|
LISTEN_ADDR | :8080 | no | HTTP listen address |
SEARXNG_BACKEND_URL | http://searxng-primary:8080 | no | SearXNG instance URL |
FALLBACK_PROVIDERS | brave | no | Comma-separated list of premium provider names |
BRAVE_API_KEY | — | no | Brave Search API key |
EXA_API_KEY | — | no | Exa Search API key |
JINA_API_KEY | — | no | Jina Search API key |
TAVILY_API_KEY | — | no | Tavily Search API key |
SUFFICIENT_MIN_RESULTS | 1 | no | Target merged result count; loop stops when reached (recommend 10 with premiums) |
T1_PREMIUM_COUNT | 0 | no | Number of premium providers to call in the hot path while SearXNG runs (0 = none; premium calls are serial within the pass) |
FALLBACK_TIMEOUT_SECONDS | 30 | no | Maximum time for speculative execution + fallback loop |
SEARXNG_TIMEOUT_SECONDS | 25 | no | Per-request timeout for SearXNG |
SEARXNG_FAIL_THRESHOLD | 6 | no | Consecutive SearXNG failures before cooldown |
SEARXNG_FAIL_COOLDOWN_SECONDS | 180 | no | Cooldown duration for SearXNG (seconds) |
BRAVE_FAIL_THRESHOLD | 3 | no | Consecutive Brave failures before cooldown |
BRAVE_FAIL_COOLDOWN_SECONDS | 300 | no | Cooldown duration for Brave (seconds) |
BRAVE_TIMEOUT_SECONDS | 15 | no | Per-request timeout for Brave API |
CACHE_SIZE | 1000 | no | LRU cache entries (in-memory) |
CACHE_TTL_SECONDS | 3600 | no | Cache entry TTL (seconds) |
LOG_LEVEL | info | no | Log level (debug, info, warn, error) |
METRICS_PATH | /metrics | no | Prometheus metrics endpoint path |
Implement the SearchBackend interface in backends/:
type MyProvider struct { APIKey string; Timeout time.Duration }
func (m *MyProvider) Name() string { return "myprovider" }
func (m *MyProvider) IsAvailable() bool { return m.APIKey != "" }
func (m *MyProvider) Search(opts SearchOptions) ([]SearchResult, error) { /* ... */ }
Then add a case to backends/factory.go and set MYPROVIDER_API_KEY in the environment. The rest (registry, fallback chain, circuit breaker) is automatic.
docker-compose.example.yml — SearXNG + gateway, one commandexamples/searxng/ — reference SearXNG config with multi-tier engine postureexamples/searxng-engines/ — custom SearXNG engines (Serper, Mojeek API)docs/architecture.md — design decisions, circuit breaker, metricsdocs/keyless.md — how to run without any API keysThe image is built and pushed automatically by GitHub Actions on every push to main and on v* tags: see .github/workflows/build.yml (docker/build-push-action@v6, platforms linux/amd64,linux/arm64, gha cache, push to GHCR). No local multi-arch build needed — the local multiarch buildx builder was removed from the homelab (2026-08-04).
MIT — see LICENSE.
31 commits
Go
98.8%