1- from typing import Any , Iterator , Optional , TypeVar
1+ from datetime import timedelta
2+ from typing import Any , Callable , Generator , Iterator , Optional , TypeVar
23
3- from requests import Session
4+ import backoff
5+ from backoff .types import Details
6+ from django .utils .timezone import now as timezone_now
7+ from requests import RequestException , Session
8+ from rest_framework .status import HTTP_429_TOO_MANY_REQUESTS
49
510from integrations .launch_darkly import types as ld_types
611from integrations .launch_darkly .constants import (
12+ BACKOFF_MAX_RETRIES ,
713 LAUNCH_DARKLY_API_BASE_URL ,
814 LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE ,
915 LAUNCH_DARKLY_API_VERSION ,
1016)
17+ from integrations .launch_darkly .exceptions import LaunchDarklyRateLimitError
1118
1219T = TypeVar ("T" )
1320
1421
22+ def launch_darkly_backoff (
23+ _get_json_response : Callable [..., T ],
24+ ) -> Callable [..., T ]:
25+ # Handle LaunchDarkly rate limiting according to their API documentation:
26+ # https://launchdarkly.com/docs/api
27+ #
28+ # 1. If a request returns a 429 Too Many Requests status code, the client should back off.
29+ # 2. When backing off, the client retries the request after the time specified in the `Retry-After` header
30+ # or the `X-Ratelimit-Reset` header, if present, or a default of `BACKOFF_DEFAULT_RETRY_SECONDS`.
31+ # 3. After `BACKOFF_MAX_RETRIES` retries, we give up.
32+ # If the last error was a 429 and contained retry information,
33+ # signal for the import request to be retried later by raising a `LaunchDarklyRateLimitError`.
34+
35+ def _get_retry_after (exc : RequestException ) -> float | None :
36+ if (
37+ (response := exc .response ) is not None
38+ ) and response .status_code == HTTP_429_TOO_MANY_REQUESTS :
39+ headers = response .headers
40+ if retry_after := headers .get ("Retry-After" ):
41+ return float (retry_after )
42+ if ratelimit_reset := headers .get ("X-Ratelimit-Reset" ):
43+ return float (ratelimit_reset ) - timezone_now ().timestamp ()
44+ return None
45+
46+ def _wait_gen () -> Generator [float , None , None ]:
47+ exc : RequestException = yield # type: ignore[misc,assignment]
48+
49+ while True :
50+ if retry_after := _get_retry_after (exc ):
51+ yield retry_after
52+ else :
53+ return
54+
55+ def _handle_giveup (
56+ details : Details ,
57+ ) -> None :
58+ exc : RequestException = details ["exception" ] # type: ignore[typeddict-item]
59+
60+ if retry_after := _get_retry_after (exc ):
61+ raise LaunchDarklyRateLimitError (
62+ retry_at = timezone_now () + timedelta (seconds = retry_after )
63+ )
64+
65+ raise exc
66+
67+ return backoff .on_exception (
68+ wait_gen = _wait_gen ,
69+ exception = RequestException ,
70+ jitter = backoff .random_jitter ,
71+ on_giveup = _handle_giveup ,
72+ max_tries = BACKOFF_MAX_RETRIES ,
73+ )(_get_json_response )
74+
75+
1576class LaunchDarklyClient :
1677 def __init__ (self , token : str ) -> None :
1778 client_session = Session ()
@@ -23,6 +84,7 @@ def __init__(self, token: str) -> None:
2384 )
2485 self .client_session = client_session
2586
87+ @launch_darkly_backoff
2688 def _get_json_response (
2789 self ,
2890 endpoint : str ,
@@ -53,7 +115,7 @@ def _iter_paginated_items(
53115 if additional_params :
54116 params .update (additional_params )
55117
56- response_json = self ._get_json_response ( # type: ignore[var-annotated]
118+ response_json : dict [ str , Any ] = self ._get_json_response (
57119 endpoint = collection_endpoint ,
58120 params = params ,
59121 )
0 commit comments