22
33import json
44import logging
5+ import time
56import urllib .error
67import urllib .request
78from typing import Any , Dict , List , Optional , TypedDict
1314
1415_BASE_URL = "https://app.launchdarkly.com"
1516
17+ _MAX_RETRIES = 3
18+ _INITIAL_BACKOFF = 1.0 # seconds; doubles on each attempt (1s, 2s, 4s)
19+
20+ # Status codes that warrant a retry. Everything else (including 400, 401, 403,
21+ # 404) is a permanent or auth failure — retrying would not help and could lead
22+ # to corrupted optimization results if some requests succeed and others fail.
23+ _RETRYABLE_STATUS_CODES = frozenset ({429 , 500 , 502 , 503 , 504 })
24+
1625
1726class LDApiError (Exception ):
1827 """Raised when the LaunchDarkly REST API returns an error or is unreachable.
@@ -186,35 +195,74 @@ def __repr__(self) -> str:
186195 def _auth_headers (self ) -> Dict [str , str ]:
187196 return {"Authorization" : self ._api_key }
188197
189- def _request (self , method : str , path : str , body : Any = None ) -> Any :
198+ def _request (
199+ self ,
200+ method : str ,
201+ path : str ,
202+ body : Any = None ,
203+ extra_headers : Optional [Dict [str , str ]] = None ,
204+ ) -> Any :
205+ """Execute an HTTP request with automatic retry and exponential backoff.
206+
207+ Retries up to ``_MAX_RETRIES`` times for transient errors (429, 5xx,
208+ network failures) with exponential backoff starting at ``_INITIAL_BACKOFF``
209+ seconds. Non-retryable status codes (400, 401, 403, 404, …) are raised
210+ immediately without retrying.
211+
212+ :param method: HTTP method (GET, POST, PATCH, …).
213+ :param path: API path, appended to ``self._base_url``.
214+ :param body: Optional request body; serialised to JSON.
215+ :param extra_headers: Additional headers merged with the auth header.
216+ :raises LDApiError: After all retry attempts are exhausted, or immediately
217+ for non-retryable status codes.
218+ """
190219 url = f"{ self ._base_url } { path } "
220+ headers = {** self ._auth_headers (), ** (extra_headers or {})}
191221 data = json .dumps (body ).encode () if body is not None else None
192- headers = self ._auth_headers ()
193222 if data is not None :
194223 headers ["Content-Type" ] = "application/json"
195- req = urllib .request .Request (url , data = data , headers = headers , method = method )
196- try :
197- with urllib .request .urlopen (req ) as resp :
198- raw = resp .read ()
199- return json .loads (raw ) if raw else None
200- except urllib .error .HTTPError as exc :
201- body_excerpt = exc .read (500 ).decode (errors = "replace" )
202- hint = _HTTP_ERROR_HINTS .get (exc .code , "" )
203- detail = f"{ hint } (API response: { body_excerpt } )" if hint else f"API response: { body_excerpt } "
204- raise LDApiError (
205- f"LaunchDarkly API error { exc .code } { exc .msg } for { method } { path } . { detail } " ,
206- status_code = exc .code ,
207- path = path ,
208- ) from exc
209- except urllib .error .URLError as exc :
210- raise LDApiError (
211- f"Could not reach LaunchDarkly API at { url } : { exc .reason } . "
212- "Check your network connection and the base_url setting." ,
213- path = path ,
214- ) from exc
215-
216- def _ai_config_headers (self ) -> Dict [str , str ]:
217- return {** self ._auth_headers (), "LD-API-Version" : "beta" }
224+
225+ last_exc : Optional [LDApiError ] = None
226+ for attempt in range (_MAX_RETRIES + 1 ):
227+ req = urllib .request .Request (url , data = data , headers = headers , method = method )
228+ try :
229+ with urllib .request .urlopen (req ) as resp :
230+ raw = resp .read ()
231+ return json .loads (raw ) if raw else None
232+ except urllib .error .HTTPError as exc :
233+ body_excerpt = exc .read (500 ).decode (errors = "replace" )
234+ hint = _HTTP_ERROR_HINTS .get (exc .code , "" )
235+ detail = f"{ hint } (API response: { body_excerpt } )" if hint else f"API response: { body_excerpt } "
236+ api_error = LDApiError (
237+ f"LaunchDarkly API error { exc .code } { exc .msg } for { method } { path } . { detail } " ,
238+ status_code = exc .code ,
239+ path = path ,
240+ )
241+ if exc .code not in _RETRYABLE_STATUS_CODES :
242+ raise api_error from exc
243+ last_exc = api_error
244+ except urllib .error .URLError as exc :
245+ last_exc = LDApiError (
246+ f"Could not reach LaunchDarkly API at { url } : { exc .reason } . "
247+ "Check your network connection and the base_url setting." ,
248+ path = path ,
249+ )
250+
251+ if attempt < _MAX_RETRIES :
252+ delay = _INITIAL_BACKOFF * (2 ** attempt )
253+ logger .warning (
254+ "LaunchDarkly API request failed (attempt %d/%d, path=%s), "
255+ "retrying in %.1fs: %s" ,
256+ attempt + 1 ,
257+ _MAX_RETRIES + 1 ,
258+ path ,
259+ delay ,
260+ last_exc ,
261+ )
262+ time .sleep (delay )
263+
264+ assert last_exc is not None
265+ raise last_exc
218266
219267 def get_model_configs (self , project_key : str ) -> List [Dict [str , Any ]]:
220268 """Fetch all AI model configs for a project.
@@ -224,26 +272,8 @@ def get_model_configs(self, project_key: str) -> List[Dict[str, Any]]:
224272 :raises LDApiError: On non-200 HTTP responses or network errors.
225273 """
226274 path = f"/api/v2/projects/{ project_key } /ai-configs/model-configs"
227- url = f"{ self ._base_url } { path } "
228- req = urllib .request .Request (url , headers = self ._ai_config_headers (), method = "GET" )
229- try :
230- with urllib .request .urlopen (req ) as resp :
231- raw = resp .read ()
232- return json .loads (raw ) if raw else []
233- except urllib .error .HTTPError as exc :
234- body_excerpt = exc .read (500 ).decode (errors = "replace" )
235- hint = _HTTP_ERROR_HINTS .get (exc .code , "" )
236- detail = f"{ hint } (API response: { body_excerpt } )" if hint else f"API response: { body_excerpt } "
237- raise LDApiError (
238- f"LaunchDarkly API error { exc .code } { exc .msg } for GET { path } . { detail } " ,
239- status_code = exc .code ,
240- path = path ,
241- ) from exc
242- except urllib .error .URLError as exc :
243- raise LDApiError (
244- f"Could not reach LaunchDarkly API at { url } : { exc .reason } ." ,
245- path = path ,
246- ) from exc
275+ result = self ._request ("GET" , path , extra_headers = {"LD-API-Version" : "beta" })
276+ return result if isinstance (result , list ) else []
247277
248278 def get_ai_config (self , project_key : str , config_key : str ) -> Any :
249279 """Fetch a single AI Config by key, including its variations.
@@ -254,27 +284,7 @@ def get_ai_config(self, project_key: str, config_key: str) -> Any:
254284 :raises LDApiError: On non-200 HTTP responses or network errors.
255285 """
256286 path = f"/api/v2/projects/{ project_key } /ai-configs/{ config_key } "
257- headers = self ._ai_config_headers ()
258- url = f"{ self ._base_url } { path } "
259- req = urllib .request .Request (url , headers = headers , method = "GET" )
260- try :
261- with urllib .request .urlopen (req ) as resp :
262- raw = resp .read ()
263- return json .loads (raw ) if raw else None
264- except urllib .error .HTTPError as exc :
265- body_excerpt = exc .read (500 ).decode (errors = "replace" )
266- hint = _HTTP_ERROR_HINTS .get (exc .code , "" )
267- detail = f"{ hint } (API response: { body_excerpt } )" if hint else f"API response: { body_excerpt } "
268- raise LDApiError (
269- f"LaunchDarkly API error { exc .code } { exc .msg } for GET { path } . { detail } " ,
270- status_code = exc .code ,
271- path = path ,
272- ) from exc
273- except urllib .error .URLError as exc :
274- raise LDApiError (
275- f"Could not reach LaunchDarkly API at { url } : { exc .reason } ." ,
276- path = path ,
277- ) from exc
287+ return self ._request ("GET" , path , extra_headers = {"LD-API-Version" : "beta" })
278288
279289 def create_ai_config_variation (
280290 self , project_key : str , config_key : str , payload : Dict [str , Any ]
@@ -288,28 +298,7 @@ def create_ai_config_variation(
288298 :raises LDApiError: On non-200 HTTP responses or network errors.
289299 """
290300 path = f"/api/v2/projects/{ project_key } /ai-configs/{ config_key } /variations"
291- url = f"{ self ._base_url } { path } "
292- data = json .dumps (payload ).encode ()
293- headers = {** self ._ai_config_headers (), "Content-Type" : "application/json" }
294- req = urllib .request .Request (url , data = data , headers = headers , method = "POST" )
295- try :
296- with urllib .request .urlopen (req ) as resp :
297- raw = resp .read ()
298- return json .loads (raw ) if raw else None
299- except urllib .error .HTTPError as exc :
300- body_excerpt = exc .read (500 ).decode (errors = "replace" )
301- hint = _HTTP_ERROR_HINTS .get (exc .code , "" )
302- detail = f"{ hint } (API response: { body_excerpt } )" if hint else f"API response: { body_excerpt } "
303- raise LDApiError (
304- f"LaunchDarkly API error { exc .code } { exc .msg } for POST { path } . { detail } " ,
305- status_code = exc .code ,
306- path = path ,
307- ) from exc
308- except urllib .error .URLError as exc :
309- raise LDApiError (
310- f"Could not reach LaunchDarkly API at { url } : { exc .reason } ." ,
311- path = path ,
312- ) from exc
301+ return self ._request ("POST" , path , body = payload , extra_headers = {"LD-API-Version" : "beta" })
313302
314303 def get_agent_optimization (
315304 self , project_key : str , optimization_key : str
0 commit comments