Skip to content

Commit 4fbb3ca

Browse files
ilya-bogin-keenableclaude
authored andcommitted
keenable web search: address review feedback
- Restrict retries to transient failures (429/5xx + network) via RetryableKeenableSearchError instead of retrying every Exception, so a bad API key (401/403) fails fast instead of 3x backoff. - test_connection auth-error detection now keys off explicit status codes and "api key" (not the bare "api" substring, which matched api.keenable.ai in generic request errors); add a dedicated rate-limit branch. - UI subtitle: "Keenable Search API" to match sibling providers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 056cd3b commit 4fbb3ca

2 files changed

Lines changed: 85 additions & 13 deletions

File tree

backend/onyx/tools/tool_implementations/web_search/clients/keenable_client.py

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
2+
from typing import Any
23
from urllib.parse import urlsplit
34

4-
55
import requests
66
from fastapi import HTTPException
77

@@ -16,6 +16,10 @@
1616
KEENABLE_REQUEST_TIMEOUT_SECONDS = 30
1717

1818

19+
class RetryableKeenableSearchError(Exception):
20+
"""Error type used to trigger retry for transient Keenable search failures."""
21+
22+
1923
class KeenableClient(WebSearchProvider):
2024
"""Keenable web search provider.
2125
@@ -64,17 +68,34 @@ def _headers(self) -> dict[str, str]:
6468
headers["X-API-Key"] = self._api_key
6569
return headers
6670

67-
@retry_builder(tries=3, delay=1, backoff=2)
68-
def search(self, query: str) -> list[WebSearchResult]:
71+
@retry_builder(
72+
tries=3,
73+
delay=1,
74+
backoff=2,
75+
exceptions=(RetryableKeenableSearchError,),
76+
)
77+
def _search_with_retries(self, query: str) -> list[WebSearchResult]:
6978
# Keyless public endpoint by default; keyed endpoint when a key is set.
7079
path = "/v1/search" if self._api_key else "/v1/search/public"
71-
response = requests.post(
72-
f"{self._base_url}{path}",
73-
headers=self._headers(),
74-
data=json.dumps({"query": query, "mode": "pro"}),
75-
timeout=KEENABLE_REQUEST_TIMEOUT_SECONDS,
76-
)
77-
response.raise_for_status()
80+
try:
81+
response = requests.post(
82+
f"{self._base_url}{path}",
83+
headers=self._headers(),
84+
data=json.dumps({"query": query, "mode": "pro"}),
85+
timeout=KEENABLE_REQUEST_TIMEOUT_SECONDS,
86+
)
87+
except requests.RequestException as exc:
88+
raise RetryableKeenableSearchError(
89+
f"Keenable search request failed: {exc}"
90+
) from exc
91+
92+
try:
93+
response.raise_for_status()
94+
except requests.HTTPError as exc:
95+
error_msg = _build_error_message(response)
96+
if _is_retryable_status(response.status_code):
97+
raise RetryableKeenableSearchError(error_msg) from exc
98+
raise ValueError(error_msg) from exc
7899

79100
body = response.json()
80101
results = body.get("results") if isinstance(body, dict) else None
@@ -100,6 +121,12 @@ def search(self, query: str) -> list[WebSearchResult]:
100121

101122
return validated_results
102123

124+
def search(self, query: str) -> list[WebSearchResult]:
125+
try:
126+
return self._search_with_retries(query)
127+
except RetryableKeenableSearchError as exc:
128+
raise ValueError(str(exc)) from exc
129+
103130
def test_connection(self) -> dict[str, str]:
104131
try:
105132
test_results = self.search("test")
@@ -110,17 +137,62 @@ def test_connection(self) -> dict[str, str]:
110137
)
111138
except HTTPException:
112139
raise
113-
except Exception as e:
140+
except (ValueError, requests.RequestException) as e:
114141
error_msg = str(e)
115-
if any(t in error_msg.lower() for t in ("api", "key", "auth")):
142+
lower = error_msg.lower()
143+
if (
144+
"status 401" in lower
145+
or "status 403" in lower
146+
or "api key" in lower
147+
or "auth" in lower
148+
):
116149
raise HTTPException(
117150
status_code=400,
118151
detail=f"Invalid Keenable API key: {error_msg}",
119152
) from e
153+
if "status 429" in lower or "rate limit" in lower:
154+
raise HTTPException(
155+
status_code=400,
156+
detail=f"Keenable rate limit exceeded: {error_msg}",
157+
) from e
120158
raise HTTPException(
121159
status_code=400,
122160
detail=f"Keenable validation failed: {error_msg}",
123161
) from e
124162

125163
logger.info("Web search provider test succeeded for Keenable.")
126164
return {"status": "ok"}
165+
166+
167+
def _build_error_message(response: requests.Response) -> str:
168+
return (
169+
f"Keenable search failed (status {response.status_code}): "
170+
f"{_extract_error_detail(response)}"
171+
)
172+
173+
174+
def _extract_error_detail(response: requests.Response) -> str:
175+
try:
176+
payload: Any = response.json()
177+
except Exception:
178+
text = response.text.strip()
179+
return text[:200] if text else "No error details"
180+
181+
if isinstance(payload, dict):
182+
error = payload.get("error")
183+
if isinstance(error, dict):
184+
detail = error.get("detail") or error.get("message")
185+
if isinstance(detail, str):
186+
return detail
187+
if isinstance(error, str):
188+
return error
189+
190+
message = payload.get("message") or payload.get("detail")
191+
if isinstance(message, str):
192+
return message
193+
194+
return str(payload)[:200]
195+
196+
197+
def _is_retryable_status(status_code: int) -> bool:
198+
return status_code == 429 or status_code >= 500

web/src/lib/webSearch/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const SEARCH_PROVIDER_DETAILS: Record<
7171
},
7272
keenable: {
7373
label: "Keenable",
74-
subtitle: "Keenable",
74+
subtitle: "Keenable Search API",
7575
helper:
7676
"Connect to Keenable to set up web search. Works without an API key (keyless free tier); add a key to lift rate limits.",
7777
logo: SvgKeenable,

0 commit comments

Comments
 (0)