Skip to content

Commit 3d11eb1

Browse files
authored
feat: Handle LaunchDarkly rate limiting (#5501)
1 parent 4067cd0 commit 3d11eb1

11 files changed

Lines changed: 284 additions & 17 deletions

File tree

api/app/settings/common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,10 @@
11471147
RECURRING_TASK_RUN_RETENTION_DAYS = env.int(
11481148
"RECURRING_TASK_RUN_RETENTION_DAYS", default=30
11491149
)
1150+
TASK_BACKOFF_DEFAULT_DELAY_SECONDS = env.int(
1151+
"TASK_BACKOFF_DEFAULT_DELAY_SECONDS",
1152+
default=5,
1153+
)
11501154

11511155
# Webhook settings
11521156
DISABLE_WEBHOOKS = env.bool("DISABLE_WEBHOOKS", False)

api/integrations/launch_darkly/client.py

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,78 @@
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

510
from integrations.launch_darkly import types as ld_types
611
from 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

1219
T = 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+
1576
class 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
)

api/integrations/launch_darkly/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@
66

77
LAUNCH_DARKLY_IMPORTED_TAG_COLOR = "#3d4db6"
88
LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL = "Imported"
9+
10+
BACKOFF_MAX_RETRIES = 5
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from datetime import datetime
2+
3+
4+
class LaunchDarklyAPIError(Exception):
5+
"""Base exception for LaunchDarkly integration errors."""
6+
7+
8+
class LaunchDarklyRateLimitError(LaunchDarklyAPIError):
9+
"""Exception raised when the LaunchDarkly API rate limit is exceeded."""
10+
11+
def __init__(self, retry_at: datetime):
12+
super().__init__()
13+
self.retry_at = retry_at

api/integrations/launch_darkly/services.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import re
33
from contextlib import contextmanager
4-
from typing import Callable, Optional, Tuple
4+
from typing import Callable, Generator, Optional, Tuple
55

66
from django.core import signing
77
from django.utils import timezone
@@ -59,10 +59,10 @@ def _log_error(
5959
import_request.status["error_messages"] += [error_message]
6060

6161

62-
@contextmanager # type: ignore[arg-type]
63-
def _complete_import_request( # type: ignore[misc]
62+
@contextmanager
63+
def _complete_import_request(
6464
import_request: LaunchDarklyImportRequest,
65-
) -> None:
65+
) -> Generator[None, None, None]:
6666
"""
6767
Wrap code so the import request always ends up completed.
6868
Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
1+
import structlog
12
from task_processor.decorators import (
23
register_task_handler,
34
)
5+
from task_processor.exceptions import TaskBackoffError
46

7+
from integrations.launch_darkly.exceptions import LaunchDarklyRateLimitError
58
from integrations.launch_darkly.models import LaunchDarklyImportRequest
69
from integrations.launch_darkly.services import process_import_request
710

11+
logger = structlog.get_logger("launch_darkly")
12+
813

914
@register_task_handler()
1015
def process_launch_darkly_import_request(import_request_id: int) -> None:
1116
import_request = LaunchDarklyImportRequest.objects.get(id=import_request_id)
12-
process_import_request(import_request)
17+
log = logger.bind(
18+
import_request_id=import_request.id,
19+
ld_project_key=import_request.ld_project_key,
20+
organisation_id=import_request.project.organisation_id,
21+
project_id=import_request.project_id,
22+
)
23+
try:
24+
process_import_request(import_request)
25+
except LaunchDarklyRateLimitError as exc:
26+
log.warning(
27+
"import-rate-limit-reached",
28+
retry_at=exc.retry_at,
29+
error_message=str(exc),
30+
)
31+
raise TaskBackoffError(delay_until=exc.retry_at) from exc

api/poetry.lock

Lines changed: 25 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ pygithub = "2.1.1"
160160
hubspot-api-client = "^8.2.1"
161161
djangorestframework-dataclasses = "^1.3.1"
162162
pyotp = "^2.9.0"
163-
flagsmith-common = "^1.13.0"
163+
flagsmith-common = "^1.14.0"
164164
django-stubs = "^5.1.3"
165165
tzdata = "^2024.1"
166166
djangorestframework-simplejwt = "^5.3.1"
@@ -236,7 +236,7 @@ types-psycopg2 = "^2.9.21.20250121"
236236
types-python-dateutil = "^2.9.0.20241206"
237237
types-pytz = "^2025.1.0.20250204"
238238
ruff = "^0.9.7"
239-
flagsmith-common = { version = "^1.13.0", extras = ["test-tools"] }
239+
flagsmith-common = { version = "*", extras = ["test-tools"] }
240240

241241
[build-system]
242242
requires = ["poetry>=2.0.0"]

api/tests/unit/integrations/launch_darkly/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def ld_token() -> str:
2424

2525
@pytest.fixture
2626
def ld_client_mock(mocker: MockerFixture) -> MagicMock:
27-
ld_client_mock = mocker.MagicMock(spec=LaunchDarklyClient)
27+
ld_client_mock: MagicMock = mocker.MagicMock(spec=LaunchDarklyClient)
2828

2929
for method_name, response_data_path in {
3030
"get_project": "client_responses/get_project.json",
@@ -39,7 +39,7 @@ def ld_client_mock(mocker: MockerFixture) -> MagicMock:
3939
ld_client_mock.get_flag_count.return_value = 9
4040
ld_client_mock.get_flag_tags.return_value = ["testtag", "testtag2"]
4141

42-
return ld_client_mock # type: ignore[no-any-return]
42+
return ld_client_mock
4343

4444

4545
@pytest.fixture

0 commit comments

Comments
 (0)