From d59fea9553da80cf9c4805be588a42f7a31eb048 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 28 May 2025 18:37:07 +0100 Subject: [PATCH 01/10] feat: Handle LaunchDarkly rate limiting --- api/integrations/launch_darkly/client.py | 59 +++++++++++++++++++- api/integrations/launch_darkly/constants.py | 3 + api/integrations/launch_darkly/exceptions.py | 13 +++++ api/integrations/launch_darkly/services.py | 8 +-- api/integrations/launch_darkly/tasks.py | 7 ++- api/poetry.lock | 38 ++++++++++--- api/pyproject.toml | 2 +- 7 files changed, 114 insertions(+), 16 deletions(-) create mode 100644 api/integrations/launch_darkly/exceptions.py diff --git a/api/integrations/launch_darkly/client.py b/api/integrations/launch_darkly/client.py index 6a5082b97140..ed31338973ce 100644 --- a/api/integrations/launch_darkly/client.py +++ b/api/integrations/launch_darkly/client.py @@ -1,17 +1,69 @@ -from typing import Any, Iterator, Optional, TypeVar +from datetime import timedelta +from typing import Any, Callable, 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, Response, 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_DEFAULT_RETRY_SECONDS, + 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 and raise a `LaunchDarklyRateLimitError`. + # If the last error was a 429 and contained retry information, + # the whole import request will be retried after the specified time. + + def _should_backoff(response: Response) -> bool: + return response.status_code == HTTP_429_TOO_MANY_REQUESTS + + def _get_retry_after(response: Response) -> int: + headers = response.headers + if retry_after := headers.get("Retry-After"): + return int(retry_after) + if ratelimit_reset := headers.get("X-Ratelimit-Reset"): + return int(ratelimit_reset) - int(timezone_now().timestamp()) + return BACKOFF_DEFAULT_RETRY_SECONDS + + def _handle_giveup( + details: Details, + ) -> None: + response: Response = details["value"] + retry_after = _get_retry_after(response) + raise LaunchDarklyRateLimitError( + retry_at=timezone_now() + timedelta(seconds=retry_after) + ) + + return backoff.on_exception( + wait_gen=backoff.runtime, + exception=RequestException, + jitter=backoff.full_jitter, + predicate=_should_backoff, + value=_get_retry_after, + 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 +75,7 @@ def __init__(self, token: str) -> None: ) self.client_session = client_session + @launch_darkly_backoff def _get_json_response( self, endpoint: str, @@ -53,7 +106,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..73b890354cff 100644 --- a/api/integrations/launch_darkly/constants.py +++ b/api/integrations/launch_darkly/constants.py @@ -6,3 +6,6 @@ LAUNCH_DARKLY_IMPORTED_TAG_COLOR = "#3d4db6" LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL = "Imported" + +BACKOFF_DEFAULT_RETRY_SECONDS = 10 +BACKOFF_MAX_RETRIES = 10 diff --git a/api/integrations/launch_darkly/exceptions.py b/api/integrations/launch_darkly/exceptions.py new file mode 100644 index 000000000000..1b28deec36ee --- /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 | None = None): + 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..a8eb32607881 100644 --- a/api/integrations/launch_darkly/tasks.py +++ b/api/integrations/launch_darkly/tasks.py @@ -1,7 +1,9 @@ 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 @@ -9,4 +11,7 @@ @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) + try: + process_import_request(import_request) + except LaunchDarklyRateLimitError as exc: + raise TaskBackoffError(delay_until=exc.retry_at) from exc diff --git a/api/poetry.lock b/api/poetry.lock index eb2d8a52c2fd..2c577e7d9907 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" @@ -1930,12 +1930,10 @@ name = "flagsmith-common" version = "1.13.0" description = "Flagsmith's common library" optional = false -python-versions = "<4.0,>=3.11" +python-versions = ">=3.11,<4.0" 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"}, -] +files = [] +develop = false [package.dependencies] backoff = ">=2.2.1,<3.0.0" @@ -1957,6 +1955,12 @@ simplejson = ">=3,<4" [package.extras] test-tools = ["pyfakefs (>=5,<6)"] +[package.source] +type = "git" +url = "https://github.com/flagsmith/flagsmith-common" +reference = "feat/task-backoff" +resolved_reference = "af18bca467b8af164e99822927f11d25a5f238eb" + [[package]] name = "flagsmith-flag-engine" version = "5.3.0" @@ -2780,6 +2784,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 +4197,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 +4205,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 +4230,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 +4238,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 +5350,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.11,<3.13" -content-hash = "8340bf542dc02ad88c7ef6e6021b99132015cd143a60292d13ca2ef00b6c8899" +content-hash = "fb16061fcf7c687fccf65337fa684f25764efa8ecd42e306ff02bb50ac49ab96" diff --git a/api/pyproject.toml b/api/pyproject.toml index cc3a3e3f917c..c4c9b964f3b8 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 = { git = "https://github.com/flagsmith/flagsmith-common", branch = "feat/task-backoff" } django-stubs = "^5.1.3" tzdata = "^2024.1" djangorestframework-simplejwt = "^5.3.1" From 82ceeb5f7e3b873cd611595b6a1495fdbd16b3ab Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 28 May 2025 18:38:08 +0100 Subject: [PATCH 02/10] clarify types --- api/integrations/launch_darkly/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/integrations/launch_darkly/exceptions.py b/api/integrations/launch_darkly/exceptions.py index 1b28deec36ee..7a66b996c4fb 100644 --- a/api/integrations/launch_darkly/exceptions.py +++ b/api/integrations/launch_darkly/exceptions.py @@ -8,6 +8,6 @@ class LaunchDarklyAPIError(Exception): class LaunchDarklyRateLimitError(LaunchDarklyAPIError): """Exception raised when the LaunchDarkly API rate limit is exceeded.""" - def __init__(self, retry_at: datetime | None = None): + def __init__(self, retry_at: datetime): super().__init__() self.retry_at = retry_at From 46bda29f9feeba1084f0c0697bd93f25e12624e6 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 28 May 2025 18:47:30 +0100 Subject: [PATCH 03/10] fix backoff instantiation --- api/integrations/launch_darkly/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/integrations/launch_darkly/client.py b/api/integrations/launch_darkly/client.py index ed31338973ce..8451f6e8ad9e 100644 --- a/api/integrations/launch_darkly/client.py +++ b/api/integrations/launch_darkly/client.py @@ -53,7 +53,7 @@ def _handle_giveup( retry_at=timezone_now() + timedelta(seconds=retry_after) ) - return backoff.on_exception( + return backoff.on_predicate( wait_gen=backoff.runtime, exception=RequestException, jitter=backoff.full_jitter, From 00412680d9e8a2847792d9989a63b50e87161155 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 29 May 2025 16:19:57 +0100 Subject: [PATCH 04/10] improve backoff logic --- api/integrations/launch_darkly/client.py | 57 ++++++++++++--------- api/integrations/launch_darkly/constants.py | 3 +- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/api/integrations/launch_darkly/client.py b/api/integrations/launch_darkly/client.py index 8451f6e8ad9e..0a2674f85e73 100644 --- a/api/integrations/launch_darkly/client.py +++ b/api/integrations/launch_darkly/client.py @@ -1,15 +1,14 @@ from datetime import timedelta -from typing import Any, Callable, Iterator, Optional, TypeVar +from typing import Any, Callable, Generator, Iterator, Optional, TypeVar import backoff from backoff.types import Details from django.utils.timezone import now as timezone_now -from requests import RequestException, Response, Session +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_DEFAULT_RETRY_SECONDS, BACKOFF_MAX_RETRIES, LAUNCH_DARKLY_API_BASE_URL, LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE, @@ -29,36 +28,46 @@ def launch_darkly_backoff( # 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 and raise a `LaunchDarklyRateLimitError`. + # 3. After `BACKOFF_MAX_RETRIES` retries, we give up. # If the last error was a 429 and contained retry information, - # the whole import request will be retried after the specified time. + # signal for the import request to be retried later by raising a `LaunchDarklyRateLimitError`. + + def _get_retry_after(exc: RequestException) -> int | None: + if ( + response := exc.response + ) and response.status_code == HTTP_429_TOO_MANY_REQUESTS: + headers = response.headers + if retry_after := headers.get("Retry-After"): + return int(retry_after) + if ratelimit_reset := headers.get("X-Ratelimit-Reset"): + return int(ratelimit_reset) - int(timezone_now().timestamp()) + return None + + def _wait_gen() -> Generator[float, None, None]: + exc: RequestException = yield # type: ignore[misc,assignment] - def _should_backoff(response: Response) -> bool: - return response.status_code == HTTP_429_TOO_MANY_REQUESTS - - def _get_retry_after(response: Response) -> int: - headers = response.headers - if retry_after := headers.get("Retry-After"): - return int(retry_after) - if ratelimit_reset := headers.get("X-Ratelimit-Reset"): - return int(ratelimit_reset) - int(timezone_now().timestamp()) - return BACKOFF_DEFAULT_RETRY_SECONDS + while True: + if retry_after := _get_retry_after(exc): + yield retry_after + else: + return def _handle_giveup( details: Details, ) -> None: - response: Response = details["value"] - retry_after = _get_retry_after(response) - raise LaunchDarklyRateLimitError( - retry_at=timezone_now() + timedelta(seconds=retry_after) - ) + exc: RequestException = details["value"] + + if retry_after := _get_retry_after(exc): + raise LaunchDarklyRateLimitError( + retry_at=timezone_now() + timedelta(seconds=retry_after) + ) + + raise exc - return backoff.on_predicate( - wait_gen=backoff.runtime, + return backoff.on_exception( + wait_gen=_wait_gen, exception=RequestException, jitter=backoff.full_jitter, - predicate=_should_backoff, - value=_get_retry_after, on_giveup=_handle_giveup, max_tries=BACKOFF_MAX_RETRIES, )(_get_json_response) diff --git a/api/integrations/launch_darkly/constants.py b/api/integrations/launch_darkly/constants.py index 73b890354cff..3ba47d552e62 100644 --- a/api/integrations/launch_darkly/constants.py +++ b/api/integrations/launch_darkly/constants.py @@ -7,5 +7,4 @@ LAUNCH_DARKLY_IMPORTED_TAG_COLOR = "#3d4db6" LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL = "Imported" -BACKOFF_DEFAULT_RETRY_SECONDS = 10 -BACKOFF_MAX_RETRIES = 10 +BACKOFF_MAX_RETRIES = 5 From 3003320069474bc6ef00caa9700dcaf3ecc264f4 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 29 May 2025 18:07:13 +0100 Subject: [PATCH 05/10] coverage, fixes --- api/integrations/launch_darkly/client.py | 10 +-- .../integrations/launch_darkly/conftest.py | 4 +- .../integrations/launch_darkly/test_client.py | 74 +++++++++++++++++++ .../integrations/launch_darkly/test_tasks.py | 28 +++++++ 4 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 api/tests/unit/integrations/launch_darkly/test_tasks.py diff --git a/api/integrations/launch_darkly/client.py b/api/integrations/launch_darkly/client.py index 0a2674f85e73..3b91d8350c95 100644 --- a/api/integrations/launch_darkly/client.py +++ b/api/integrations/launch_darkly/client.py @@ -32,15 +32,15 @@ def launch_darkly_backoff( # 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) -> int | None: + def _get_retry_after(exc: RequestException) -> float | None: if ( - response := exc.response + (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 int(retry_after) + return float(retry_after) if ratelimit_reset := headers.get("X-Ratelimit-Reset"): - return int(ratelimit_reset) - int(timezone_now().timestamp()) + return float(ratelimit_reset) - timezone_now().timestamp() return None def _wait_gen() -> Generator[float, None, None]: @@ -55,7 +55,7 @@ def _wait_gen() -> Generator[float, None, None]: def _handle_giveup( details: Details, ) -> None: - exc: RequestException = details["value"] + exc: RequestException = details["exception"] # type: ignore[typeddict-item] if retry_after := _get_retry_after(exc): raise LaunchDarklyRateLimitError( 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..cf11d06b1a36 100644 --- a/api/tests/unit/integrations/launch_darkly/test_client.py +++ b/api/tests/unit/integrations/launch_darkly/test_client.py @@ -1,10 +1,12 @@ import json from os.path import abspath, dirname, join +import pytest from pytest_mock import MockerFixture 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( @@ -44,6 +46,78 @@ def test_launch_darkly_client__get_project__return_expected( 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__get_environments__return_expected( mocker: MockerFixture, requests_mock: RequestsMockerFixture, 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 From c43560ead92e55cb1f4b42ad6fcf0a594984e9de Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 29 May 2025 18:27:31 +0100 Subject: [PATCH 06/10] improve coverage --- .../integrations/launch_darkly/test_client.py | 189 +++++++++++------- 1 file changed, 117 insertions(+), 72 deletions(-) diff --git a/api/tests/unit/integrations/launch_darkly/test_client.py b/api/tests/unit/integrations/launch_darkly/test_client.py index cf11d06b1a36..1785244df6d0 100644 --- a/api/tests/unit/integrations/launch_darkly/test_client.py +++ b/api/tests/unit/integrations/launch_darkly/test_client.py @@ -3,6 +3,7 @@ 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 @@ -46,78 +47,6 @@ def test_launch_darkly_client__get_project__return_expected( 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__get_environments__return_expected( mocker: MockerFixture, requests_mock: RequestsMockerFixture, @@ -297,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) From 92796b5aca8e11dd1ef7fc6bbcee1295aee575a8 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 29 May 2025 18:45:20 +0100 Subject: [PATCH 07/10] ensure retry-at values are always respected --- api/integrations/launch_darkly/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/integrations/launch_darkly/client.py b/api/integrations/launch_darkly/client.py index 3b91d8350c95..826a7f67c844 100644 --- a/api/integrations/launch_darkly/client.py +++ b/api/integrations/launch_darkly/client.py @@ -67,7 +67,7 @@ def _handle_giveup( return backoff.on_exception( wait_gen=_wait_gen, exception=RequestException, - jitter=backoff.full_jitter, + jitter=backoff.random_jitter, on_giveup=_handle_giveup, max_tries=BACKOFF_MAX_RETRIES, )(_get_json_response) From 445ebb88960301bb5d4d81c3616f3b97d8a76827 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 30 May 2025 10:47:29 +0100 Subject: [PATCH 08/10] add TASK_BACKOFF_DEFAULT_DELAY_SECONDS --- api/app/settings/common.py | 4 ++++ 1 file changed, 4 insertions(+) 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) From 949f7bf0766bbb5a9c2e985e6f31c8787b28afbc Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 30 May 2025 11:39:19 +0100 Subject: [PATCH 09/10] add logging --- api/integrations/launch_darkly/tasks.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/api/integrations/launch_darkly/tasks.py b/api/integrations/launch_darkly/tasks.py index a8eb32607881..de2249486d2c 100644 --- a/api/integrations/launch_darkly/tasks.py +++ b/api/integrations/launch_darkly/tasks.py @@ -1,3 +1,4 @@ +import structlog from task_processor.decorators import ( register_task_handler, ) @@ -7,11 +8,24 @@ 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) + 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 From f8189ce5582455ef80e18fadd9df885195b15126 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 30 May 2025 12:51:34 +0100 Subject: [PATCH 10/10] deps: bump flagsmith-common from 1.13.0 to 1.14.0 --- api/poetry.lock | 18 +++++++----------- api/pyproject.toml | 4 ++-- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index 2c577e7d9907..8847fb5837d0 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1927,13 +1927,15 @@ resolved_reference = "303954ba54fd2f402c75806b3b9ba7f2d42aa426" [[package]] name = "flagsmith-common" -version = "1.13.0" +version = "1.14.0" description = "Flagsmith's common library" optional = false -python-versions = ">=3.11,<4.0" +python-versions = "<4.0,>=3.11" groups = ["main", "dev", "split-testing", "workflows"] -files = [] -develop = false +files = [ + {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] backoff = ">=2.2.1,<3.0.0" @@ -1955,12 +1957,6 @@ simplejson = ">=3,<4" [package.extras] test-tools = ["pyfakefs (>=5,<6)"] -[package.source] -type = "git" -url = "https://github.com/flagsmith/flagsmith-common" -reference = "feat/task-backoff" -resolved_reference = "af18bca467b8af164e99822927f11d25a5f238eb" - [[package]] name = "flagsmith-flag-engine" version = "5.3.0" @@ -5350,4 +5346,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.11,<3.13" -content-hash = "fb16061fcf7c687fccf65337fa684f25764efa8ecd42e306ff02bb50ac49ab96" +content-hash = "3cce2b480e2b5fc1a7b2868f53a4e38ca4f49fadd19f4bd884004c47b72af956" diff --git a/api/pyproject.toml b/api/pyproject.toml index c4c9b964f3b8..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 = { git = "https://github.com/flagsmith/flagsmith-common", branch = "feat/task-backoff" } +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"]