diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 9007ec5c2a07..3915315746e9 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1147,6 +1147,10 @@ RECURRING_TASK_RUN_RETENTION_DAYS = env.int( "RECURRING_TASK_RUN_RETENTION_DAYS", default=30 ) +TASK_BACKOFF_DEFAULT_DELAY_SECONDS = env.int( + "TASK_BACKOFF_DEFAULT_DELAY_SECONDS", + default=5, +) # Webhook settings DISABLE_WEBHOOKS = env.bool("DISABLE_WEBHOOKS", False) diff --git a/api/integrations/launch_darkly/client.py b/api/integrations/launch_darkly/client.py index 6a5082b97140..826a7f67c844 100644 --- a/api/integrations/launch_darkly/client.py +++ b/api/integrations/launch_darkly/client.py @@ -1,17 +1,78 @@ -from typing import Any, Iterator, Optional, TypeVar +from datetime import timedelta +from typing import Any, Callable, Generator, Iterator, Optional, TypeVar -from requests import Session +import backoff +from backoff.types import Details +from django.utils.timezone import now as timezone_now +from requests import RequestException, Session +from rest_framework.status import HTTP_429_TOO_MANY_REQUESTS from integrations.launch_darkly import types as ld_types from integrations.launch_darkly.constants import ( + BACKOFF_MAX_RETRIES, LAUNCH_DARKLY_API_BASE_URL, LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE, LAUNCH_DARKLY_API_VERSION, ) +from integrations.launch_darkly.exceptions import LaunchDarklyRateLimitError T = TypeVar("T") +def launch_darkly_backoff( + _get_json_response: Callable[..., T], +) -> Callable[..., T]: + # Handle LaunchDarkly rate limiting according to their API documentation: + # https://launchdarkly.com/docs/api + # + # 1. If a request returns a 429 Too Many Requests status code, the client should back off. + # 2. When backing off, the client retries the request after the time specified in the `Retry-After` header + # or the `X-Ratelimit-Reset` header, if present, or a default of `BACKOFF_DEFAULT_RETRY_SECONDS`. + # 3. After `BACKOFF_MAX_RETRIES` retries, we give up. + # If the last error was a 429 and contained retry information, + # signal for the import request to be retried later by raising a `LaunchDarklyRateLimitError`. + + def _get_retry_after(exc: RequestException) -> float | None: + if ( + (response := exc.response) is not None + ) and response.status_code == HTTP_429_TOO_MANY_REQUESTS: + headers = response.headers + if retry_after := headers.get("Retry-After"): + return float(retry_after) + if ratelimit_reset := headers.get("X-Ratelimit-Reset"): + return float(ratelimit_reset) - timezone_now().timestamp() + return None + + def _wait_gen() -> Generator[float, None, None]: + exc: RequestException = yield # type: ignore[misc,assignment] + + while True: + if retry_after := _get_retry_after(exc): + yield retry_after + else: + return + + def _handle_giveup( + details: Details, + ) -> None: + exc: RequestException = details["exception"] # type: ignore[typeddict-item] + + if retry_after := _get_retry_after(exc): + raise LaunchDarklyRateLimitError( + retry_at=timezone_now() + timedelta(seconds=retry_after) + ) + + raise exc + + return backoff.on_exception( + wait_gen=_wait_gen, + exception=RequestException, + jitter=backoff.random_jitter, + on_giveup=_handle_giveup, + max_tries=BACKOFF_MAX_RETRIES, + )(_get_json_response) + + class LaunchDarklyClient: def __init__(self, token: str) -> None: client_session = Session() @@ -23,6 +84,7 @@ def __init__(self, token: str) -> None: ) self.client_session = client_session + @launch_darkly_backoff def _get_json_response( self, endpoint: str, @@ -53,7 +115,7 @@ def _iter_paginated_items( if additional_params: params.update(additional_params) - response_json = self._get_json_response( # type: ignore[var-annotated] + response_json: dict[str, Any] = self._get_json_response( endpoint=collection_endpoint, params=params, ) diff --git a/api/integrations/launch_darkly/constants.py b/api/integrations/launch_darkly/constants.py index c1aeca17337a..3ba47d552e62 100644 --- a/api/integrations/launch_darkly/constants.py +++ b/api/integrations/launch_darkly/constants.py @@ -6,3 +6,5 @@ LAUNCH_DARKLY_IMPORTED_TAG_COLOR = "#3d4db6" LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL = "Imported" + +BACKOFF_MAX_RETRIES = 5 diff --git a/api/integrations/launch_darkly/exceptions.py b/api/integrations/launch_darkly/exceptions.py new file mode 100644 index 000000000000..7a66b996c4fb --- /dev/null +++ b/api/integrations/launch_darkly/exceptions.py @@ -0,0 +1,13 @@ +from datetime import datetime + + +class LaunchDarklyAPIError(Exception): + """Base exception for LaunchDarkly integration errors.""" + + +class LaunchDarklyRateLimitError(LaunchDarklyAPIError): + """Exception raised when the LaunchDarkly API rate limit is exceeded.""" + + def __init__(self, retry_at: datetime): + super().__init__() + self.retry_at = retry_at diff --git a/api/integrations/launch_darkly/services.py b/api/integrations/launch_darkly/services.py index f6b676e939d4..8f01e0762211 100644 --- a/api/integrations/launch_darkly/services.py +++ b/api/integrations/launch_darkly/services.py @@ -1,7 +1,7 @@ import logging import re from contextlib import contextmanager -from typing import Callable, Optional, Tuple +from typing import Callable, Generator, Optional, Tuple from django.core import signing from django.utils import timezone @@ -59,10 +59,10 @@ def _log_error( import_request.status["error_messages"] += [error_message] -@contextmanager # type: ignore[arg-type] -def _complete_import_request( # type: ignore[misc] +@contextmanager +def _complete_import_request( import_request: LaunchDarklyImportRequest, -) -> None: +) -> Generator[None, None, None]: """ Wrap code so the import request always ends up completed. diff --git a/api/integrations/launch_darkly/tasks.py b/api/integrations/launch_darkly/tasks.py index eac5b334ae0a..de2249486d2c 100644 --- a/api/integrations/launch_darkly/tasks.py +++ b/api/integrations/launch_darkly/tasks.py @@ -1,12 +1,31 @@ +import structlog from task_processor.decorators import ( register_task_handler, ) +from task_processor.exceptions import TaskBackoffError +from integrations.launch_darkly.exceptions import LaunchDarklyRateLimitError from integrations.launch_darkly.models import LaunchDarklyImportRequest from integrations.launch_darkly.services import process_import_request +logger = structlog.get_logger("launch_darkly") + @register_task_handler() def process_launch_darkly_import_request(import_request_id: int) -> None: import_request = LaunchDarklyImportRequest.objects.get(id=import_request_id) - process_import_request(import_request) + log = logger.bind( + import_request_id=import_request.id, + ld_project_key=import_request.ld_project_key, + organisation_id=import_request.project.organisation_id, + project_id=import_request.project_id, + ) + try: + process_import_request(import_request) + except LaunchDarklyRateLimitError as exc: + log.warning( + "import-rate-limit-reached", + retry_at=exc.retry_at, + error_message=str(exc), + ) + raise TaskBackoffError(delay_until=exc.retry_at) from exc diff --git a/api/poetry.lock b/api/poetry.lock index eb2d8a52c2fd..8847fb5837d0 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1927,14 +1927,14 @@ resolved_reference = "303954ba54fd2f402c75806b3b9ba7f2d42aa426" [[package]] name = "flagsmith-common" -version = "1.13.0" +version = "1.14.0" description = "Flagsmith's common library" optional = false python-versions = "<4.0,>=3.11" groups = ["main", "dev", "split-testing", "workflows"] files = [ - {file = "flagsmith_common-1.13.0-py3-none-any.whl", hash = "sha256:fea805a7ed191b7a6dea3053f9c534da575aa1d47984da61e7aec23b97ebb2d6"}, - {file = "flagsmith_common-1.13.0.tar.gz", hash = "sha256:67c7c892253f8aeec1e2d1651248b86cfcb68ddfaab5a8c49571925f0a80f595"}, + {file = "flagsmith_common-1.14.0-py3-none-any.whl", hash = "sha256:d08d67cfdd089838df9d596b3104ffd007c5603e23558a0c84b0db40171305a8"}, + {file = "flagsmith_common-1.14.0.tar.gz", hash = "sha256:2d3af10717ec811bc068f546a6bcbc96d3987ba76806b240eb7a25ed4f9db509"}, ] [package.dependencies] @@ -2780,6 +2780,16 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -4183,6 +4193,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -4190,8 +4201,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -4208,6 +4226,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -4215,6 +4234,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -5326,4 +5346,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.11,<3.13" -content-hash = "8340bf542dc02ad88c7ef6e6021b99132015cd143a60292d13ca2ef00b6c8899" +content-hash = "3cce2b480e2b5fc1a7b2868f53a4e38ca4f49fadd19f4bd884004c47b72af956" diff --git a/api/pyproject.toml b/api/pyproject.toml index cc3a3e3f917c..b0dd72247ab3 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -160,7 +160,7 @@ pygithub = "2.1.1" hubspot-api-client = "^8.2.1" djangorestframework-dataclasses = "^1.3.1" pyotp = "^2.9.0" -flagsmith-common = "^1.13.0" +flagsmith-common = "^1.14.0" django-stubs = "^5.1.3" tzdata = "^2024.1" djangorestframework-simplejwt = "^5.3.1" @@ -236,7 +236,7 @@ types-psycopg2 = "^2.9.21.20250121" types-python-dateutil = "^2.9.0.20241206" types-pytz = "^2025.1.0.20250204" ruff = "^0.9.7" -flagsmith-common = { version = "^1.13.0", extras = ["test-tools"] } +flagsmith-common = { version = "*", extras = ["test-tools"] } [build-system] requires = ["poetry>=2.0.0"] diff --git a/api/tests/unit/integrations/launch_darkly/conftest.py b/api/tests/unit/integrations/launch_darkly/conftest.py index a7561aa6e2f9..10033c981f9a 100644 --- a/api/tests/unit/integrations/launch_darkly/conftest.py +++ b/api/tests/unit/integrations/launch_darkly/conftest.py @@ -24,7 +24,7 @@ def ld_token() -> str: @pytest.fixture def ld_client_mock(mocker: MockerFixture) -> MagicMock: - ld_client_mock = mocker.MagicMock(spec=LaunchDarklyClient) + ld_client_mock: MagicMock = mocker.MagicMock(spec=LaunchDarklyClient) for method_name, response_data_path in { "get_project": "client_responses/get_project.json", @@ -39,7 +39,7 @@ def ld_client_mock(mocker: MockerFixture) -> MagicMock: ld_client_mock.get_flag_count.return_value = 9 ld_client_mock.get_flag_tags.return_value = ["testtag", "testtag2"] - return ld_client_mock # type: ignore[no-any-return] + return ld_client_mock @pytest.fixture diff --git a/api/tests/unit/integrations/launch_darkly/test_client.py b/api/tests/unit/integrations/launch_darkly/test_client.py index 12e9b06aa99b..1785244df6d0 100644 --- a/api/tests/unit/integrations/launch_darkly/test_client.py +++ b/api/tests/unit/integrations/launch_darkly/test_client.py @@ -1,10 +1,13 @@ import json from os.path import abspath, dirname, join +import pytest from pytest_mock import MockerFixture +from requests import HTTPError from requests_mock import Mocker as RequestsMockerFixture from integrations.launch_darkly.client import LaunchDarklyClient +from integrations.launch_darkly.exceptions import LaunchDarklyRateLimitError def test_launch_darkly_client__get_project__return_expected( @@ -223,3 +226,119 @@ def test_launch_darkly_client__get_flag_tags__return_expected( # Then assert result == expected_result + + +@pytest.mark.parametrize( + "response_headers", + [{"Retry-After": "0.1"}, {"X-Ratelimit-Reset": "1672531200.1"}], +) +@pytest.mark.freeze_time("2023-01-01T00:00:00Z") +def test_launch_darkly_client__rate_limit__expected_backoff( + requests_mock: RequestsMockerFixture, + response_headers: dict[str, str], +) -> None: + # Given + token = "test-token" + project_key = "test-project-key" + + example_response_file_path = join( + dirname(abspath(__file__)), "example_api_responses/getProject.json" + ) + with open(example_response_file_path) as example_response_fp: + example_response_content = example_response_fp.read() + + expected_result = json.loads(example_response_content) + + requests_mock.get( + "https://app.launchdarkly.com/api/v2/projects/test-project-key", + [ + {"status_code": 429, "headers": response_headers}, + {"status_code": 429, "headers": response_headers}, + {"status_code": 200, "text": example_response_content}, + ], + ) + + client = LaunchDarklyClient(token=token) + + # When + result = client.get_project(project_key=project_key) + + # Then + assert result == expected_result + + +@pytest.mark.parametrize( + "response_headers", + [{"Retry-After": "0.1"}, {"X-Ratelimit-Reset": "1672531200.1"}], +) +@pytest.mark.freeze_time("2023-01-01T00:00:00Z") +def test_launch_darkly_client__rate_limit_max_retries__raises_expected( + requests_mock: RequestsMockerFixture, + response_headers: dict[str, str], +) -> None: + # Given + token = "test-token" + project_key = "test-project-key" + + requests_mock.get( + "https://app.launchdarkly.com/api/v2/projects/test-project-key", + [ + {"status_code": 429, "headers": response_headers}, + {"status_code": 429, "headers": response_headers}, + {"status_code": 429, "headers": response_headers}, + {"status_code": 429, "headers": response_headers}, + {"status_code": 429, "headers": response_headers}, + ], + ) + + client = LaunchDarklyClient(token=token) + + # When & Then + with pytest.raises(LaunchDarklyRateLimitError) as exc_info: + client.get_project(project_key=project_key) + + assert exc_info.value.retry_at.isoformat() == "2023-01-01T00:00:00.100000+00:00" + + +def test_launch_darkly_client__invalid_response__raises_expected( + requests_mock: RequestsMockerFixture, +) -> None: + # Given + token = "test-token" + project_key = "test-project-key" + + requests_mock.get( + "https://app.launchdarkly.com/api/v2/projects/test-project-key", + status_code=404, + ) + + client = LaunchDarklyClient(token=token) + + # When & Then + with pytest.raises(HTTPError): + client.get_project(project_key=project_key) + + +def test_launch_darkly_client__rate_limit_invalid_response__raises_expected( + requests_mock: RequestsMockerFixture, +) -> None: + # Given + token = "test-token" + project_key = "test-project-key" + + requests_mock.get( + "https://app.launchdarkly.com/api/v2/projects/test-project-key", + [ + {"status_code": 429, "headers": {"Retry-After": "0.1"}}, + {"status_code": 429, "headers": {"Retry-After": "0.1"}}, + {"status_code": 429, "headers": {"Retry-After": "0.1"}}, + {"status_code": 429, "headers": {"Retry-After": "0.1"}}, + {"status_code": 404}, + ], + ) + + client = LaunchDarklyClient(token=token) + + # When & Then + with pytest.raises(HTTPError): + client.get_project(project_key=project_key) diff --git a/api/tests/unit/integrations/launch_darkly/test_tasks.py b/api/tests/unit/integrations/launch_darkly/test_tasks.py new file mode 100644 index 000000000000..c1a12720416e --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/test_tasks.py @@ -0,0 +1,28 @@ +from datetime import datetime + +import pytest +from pytest_mock import MockerFixture +from task_processor.exceptions import TaskBackoffError + +from integrations.launch_darkly.exceptions import LaunchDarklyRateLimitError +from integrations.launch_darkly.models import LaunchDarklyImportRequest +from integrations.launch_darkly.tasks import process_launch_darkly_import_request + + +def test_process_import_request__rate_limit__raises_expected( + import_request: LaunchDarklyImportRequest, + mocker: MockerFixture, +) -> None: + # Given + expected_delay_until = datetime.now() + + mocker.patch( + "integrations.launch_darkly.tasks.process_import_request", + side_effect=LaunchDarklyRateLimitError(retry_at=expected_delay_until), + ) + + # When & Then + with pytest.raises(TaskBackoffError) as exc_info: + process_launch_darkly_import_request(import_request_id=import_request.id) + + assert exc_info.value.delay_until == expected_delay_until