11import json
2+ from typing import Any
23from urllib .parse import urlsplit
34
4-
55import requests
66from fastapi import HTTPException
77
1616KEENABLE_REQUEST_TIMEOUT_SECONDS = 30
1717
1818
19+ class RetryableKeenableSearchError (Exception ):
20+ """Error type used to trigger retry for transient Keenable search failures."""
21+
22+
1923class 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
0 commit comments