From f375a63873d171bffe54cfc1e30678b1a2770ece Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:15:16 -0400 Subject: [PATCH 1/3] INTEG-2955 - retry rate-limited requests --- CHANGELOG.md | 5 ++ src/_incydr_cli/core.py | 3 +- src/_incydr_sdk/core/client.py | 21 ++++++ src/_incydr_sdk/core/settings.py | 2 + src/_incydr_sdk/core/utils.py | 33 +++++++++ src/_incydr_sdk/file_events/client.py | 50 ++++++-------- tests/test_core.py | 97 ++++++++++++++++++++++++++ tests/test_file_events.py | 98 +++++++++++++++++++++++++++ 8 files changed, 277 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb2541b..658ba33d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ how a consumer would use the library or CLI tool (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added +- The `retry_on_rate_limit` setting, which controls whether the SDK will retry 429 responses. Defaults to on, retrying most 429 responses up to 3 times. Set this to False on SDK initialization if you wish to manually handle retrying 429 responses. When enabled, if retries are exhausted, requests.exceptions.RetryError will be raised. + ## 2.12.2 - 2026-06-22 ### Added diff --git a/src/_incydr_cli/core.py b/src/_incydr_cli/core.py index 435cd811..dc055643 100644 --- a/src/_incydr_cli/core.py +++ b/src/_incydr_cli/core.py @@ -3,6 +3,7 @@ import click from requests import HTTPError +from requests.exceptions import RetryError from _incydr_cli.exceptions import IncydrCLIException from _incydr_cli.exceptions import LoggedCLIError @@ -95,7 +96,7 @@ def invoke(self, ctx): raise IncydrCLIException(err.args[0]) except click.ClickException: raise - except HTTPError as err: + except (HTTPError, RetryError) as err: # log error with traceback and print error code with brief error message to console settings._log_verbose_error(self._original_args, err.request) raise LoggedCLIError(err.args[0]) diff --git a/src/_incydr_sdk/core/client.py b/src/_incydr_sdk/core/client.py index 0748c8b0..f99f6941 100644 --- a/src/_incydr_sdk/core/client.py +++ b/src/_incydr_sdk/core/client.py @@ -3,6 +3,7 @@ import logging from collections import deque +from requests.adapters import HTTPAdapter from requests_toolbelt import user_agent from requests_toolbelt.sessions import BaseUrlSession @@ -16,6 +17,7 @@ from _incydr_sdk.core.auth import APIClientAuth from _incydr_sdk.core.auth import RefreshTokenAuth from _incydr_sdk.core.settings import IncydrSettings +from _incydr_sdk.core.utils import IncydrRequestRetryStrategy from _incydr_sdk.customer.client import CustomerClient from _incydr_sdk.departments.client import DepartmentsClient from _incydr_sdk.devices.client import DevicesClient @@ -97,6 +99,25 @@ def response_hook(response, *args, **kwargs): self._session.hooks["response"] = [response_hook] + if self._settings.retry_on_rate_limit: + rate_limit_retry_strategy = IncydrRequestRetryStrategy( + logger=self._settings.logger, + total=None, + status=3, # retry up to 3 times + connect=False, + read=False, + redirect=False, + other=False, # We do not want to retry on non-status causes. + backoff_factor=5, # if `retry-after` header isn't present, use 5 second exponential backoff + status_forcelist=[ + 429 + ], # this only handles 429 errors. Does not retry 5xx. + ) + rate_limit_retry_adapter = HTTPAdapter( + max_retries=rate_limit_retry_strategy + ) + self._session.mount(self._session.base_url, rate_limit_retry_adapter) + self._actors = ActorsClient(self) self._agents = AgentsClient(self) self._alerts = AlertsClient(self) diff --git a/src/_incydr_sdk/core/settings.py b/src/_incydr_sdk/core/settings.py index c4646cc0..eddaeb4e 100644 --- a/src/_incydr_sdk/core/settings.py +++ b/src/_incydr_sdk/core/settings.py @@ -74,6 +74,7 @@ class IncydrSettings(BaseSettings): * **api_client_id**: `str` The API Client Identifier for authentication. env_var=`INCYDR_API_CLIENT_ID` * **api_client_secret**: `str` The API Client Secret for authentication. env_var=`INCYDR_API_CLIENT_SECRET` * **url**: `str` The URL of your Code42 API gateway instance. env_var=`INCYDR_URL` + * **retry_on_rate_limit**: `bool` Whether or not to automatically retry when we get a 429 response. Defaults to `True`. env_var=`INCYDR_RETRY_ON_RATE_LIMIT` * **page_size**: `int` The default page size for all paginated requests. Defaults to 100. env_var=`INCYDR_PAGE_SIZE` * **max_response_history**: `int` The maximum number of responses the `incydr.Client.response_history` list will store. Defaults to 5. env_var=`INCYDR_MAX_RESPONSE_HISTORY` @@ -91,6 +92,7 @@ class IncydrSettings(BaseSettings): api_client_id: Optional[str] = Field(default=None) api_client_secret: Optional[SecretStr] = Field(default=None) url: str + retry_on_rate_limit: bool = Field(default=True) page_size: int = Field(default=100) max_response_history: int = Field(default=5) use_rich: bool = Field(default=True) diff --git a/src/_incydr_sdk/core/utils.py b/src/_incydr_sdk/core/utils.py index ea8716d1..abdbb6e7 100644 --- a/src/_incydr_sdk/core/utils.py +++ b/src/_incydr_sdk/core/utils.py @@ -1,6 +1,8 @@ +import logging import re from requests import Response +from urllib3 import Retry def get_filename_from_content_disposition(response: Response, fallback=None) -> str: @@ -9,3 +11,34 @@ def get_filename_from_content_disposition(response: Response, fallback=None) -> if match: return match.group(1) return fallback + + +class IncydrRequestRetryStrategy(Retry): + """We subclass :class:`urllib3.Retry` just to add a bit of logging so the user can tell why the + request might look like it's hanging when we are retrying due to 429. + """ + + _logger: logging.Logger = None + + def __init__(self, *args, logger: logging.Logger = None, **kwargs): + self._logger = logger + super().__init__(*args, **kwargs) + + def new(self, **kw): + return super().new(logger=self._logger, **kw) + + def get_retry_after(self, response): + retry_after = super().get_retry_after(response) + if retry_after is not None and self._logger is not None: + self._logger.warning( + f"Rate limit hit, retrying after: {int(retry_after)} seconds." + ) + return retry_after + + def get_backoff_time(self): + backoff_time = super().get_backoff_time() + if self._logger is not None: + self._logger.warning( + f"Rate limit hit, retrying after: {backoff_time} seconds." + ) + return backoff_time diff --git a/src/_incydr_sdk/file_events/client.py b/src/_incydr_sdk/file_events/client.py index 83d30299..52852017 100644 --- a/src/_incydr_sdk/file_events/client.py +++ b/src/_incydr_sdk/file_events/client.py @@ -3,12 +3,12 @@ from pydantic import parse_obj_as from requests import HTTPError from requests.adapters import HTTPAdapter -from urllib3 import Retry from ..exceptions import IncydrException from .models.response import FileEventsPage from .models.response import GroupedFileEventResponse from .models.response import SavedSearch +from _incydr_sdk.core.utils import IncydrRequestRetryStrategy from _incydr_sdk.queries.file_events import EventQuery from _incydr_sdk.queries.file_events import GroupingEventQuery @@ -128,16 +128,26 @@ def get_saved_search(self, search_id: str) -> SavedSearch: def _mount_retry_adapter(self): """Sets custom Retry strategy for FFS url requests to gracefully handle being rate-limited on FFS queries.""" - if not self._retry_adapter_mounted: - retry_strategy = FFSQueryRetryStrategy( + if ( + not self._retry_adapter_mounted + and self._parent.settings.retry_on_rate_limit + ): + retry_strategy = IncydrRequestRetryStrategy( + logger=self._parent.settings.logger, + total=None, status=3, # retry up to 3 times + connect=False, + read=False, + redirect=False, + other=False, # We do not want to retry on non-status causes. backoff_factor=5, # if `retry-after` header isn't present, use 5 second exponential backoff allowed_methods=[ - "POST" - ], # POST isn't a default allowed method due to it usually modifying resources + "GET", + "POST", + ], # POST isn't a default allowed method due to it usually modifying resources. status_forcelist=[ 429 - ], # this only handles 429 errors, it won't retry on 5xx + ], # this only handles 429 errors. Does not retry 5xx. ) file_event_adapter = HTTPAdapter( pool_connections=200, @@ -145,7 +155,9 @@ def _mount_retry_adapter(self): pool_block=True, max_retries=retry_strategy, ) - self._parent.session.mount(self._parent.settings.url, file_event_adapter) + self._parent.session.mount( + f"{self._parent.session.base_url}/v2/file-events", file_event_adapter + ) self._retry_adapter_mounted = True @@ -159,27 +171,3 @@ def v2(self): if self._v2 is None: self._v2 = FileEventsV2(self._parent) return self._v2 - - -class FFSQueryRetryStrategy(Retry): - """The forensic search service helpfully responds with a 'retry-after' header, telling us how long until the rate - limiter is reset. We subclass :class:`urllib3.Retry` just to add a bit of logging so the user can tell why the - request might look like it's hanging. - """ - - # TODO: Handle debug logging - - def get_retry_after(self, response): - retry_after = super().get_retry_after(response) - # if retry_after is not None: - # debug.logger.info( - # f"Forensic search rate limit hit, retrying after: {int(retry_after)} seconds." - # ) - return retry_after - - def get_backoff_time(self): - backoff_time = super().get_backoff_time() - # debug.logger.info( - # f"Forensic search rate limit hit, retrying after: {backoff_time} seconds." - # ) - return backoff_time diff --git a/tests/test_core.py b/tests/test_core.py index 305c4eaf..b6c80458 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,8 +1,10 @@ from io import StringIO +from unittest.mock import MagicMock import pytest from pydantic import Field from pytest_httpserver import HTTPServer +from requests.exceptions import HTTPError from .conftest import TEST_HOST from .conftest import TEST_TOKEN @@ -10,6 +12,7 @@ from _incydr_sdk.core.models import CSVModel from _incydr_sdk.core.models import Model from _incydr_sdk.core.settings import IncydrSettings +from _incydr_sdk.core.utils import IncydrRequestRetryStrategy from _incydr_sdk.exceptions import AuthMissingError from incydr import Client @@ -243,3 +246,97 @@ def test_client_prefers_refresh_token_auth_when_both_auth_methods_provided( c = Client() assert isinstance(c._session.auth, RefreshTokenAuth) + + +def test_settings_retry_on_rate_limit_defaults_to_true(monkeypatch): + monkeypatch.setenv("incydr_url", TEST_HOST) + monkeypatch.setenv("incydr_api_client_id", "env_id") + monkeypatch.setenv("incydr_api_client_secret", "env_secret") + + settings = IncydrSettings() + assert settings.retry_on_rate_limit is True + + +def test_client_mounts_rate_limit_retry_adapter_by_default( + httpserver_auth: HTTPServer, +): + client = Client() + adapter = client.session.get_adapter(TEST_HOST) + + assert isinstance(adapter.max_retries, IncydrRequestRetryStrategy) + assert adapter.max_retries.status == 3 + assert adapter.max_retries.total is None + assert adapter.max_retries.connect is False + assert adapter.max_retries.read is False + assert not adapter.max_retries.redirect + assert not adapter.max_retries.other + assert 429 in adapter.max_retries.status_forcelist + assert adapter.max_retries._logger is client.settings.logger + + +def test_client_does_not_mount_rate_limit_retry_adapter_when_disabled( + httpserver_auth: HTTPServer, +): + client = Client(retry_on_rate_limit=False) + adapter = client.session.get_adapter(TEST_HOST) + + assert not isinstance(adapter.max_retries, IncydrRequestRetryStrategy) + assert adapter.max_retries.total == 0 + + +def test_client_retries_get_request_on_429(httpserver_auth: HTTPServer): + httpserver_auth.expect_ordered_request( + "/v1/users/user-1", method="GET" + ).respond_with_data("", status=429, headers={"Retry-After": "0"}) + httpserver_auth.expect_ordered_request( + "/v1/users/user-1", method="GET" + ).respond_with_json({"userId": "user-1"}) + + client = Client() + response = client.session.get("/v1/users/user-1") + + assert response.status_code == 200 + assert response.json() == {"userId": "user-1"} + httpserver_auth.check() + + +def test_client_does_not_retry_post_request_on_429(httpserver_auth: HTTPServer): + httpserver_auth.expect_request("/v1/cases", method="POST").respond_with_data( + "", status=429, headers={"Retry-After": "0"} + ) + + client = Client() + with pytest.raises(HTTPError) as err: + client.session.post("/v1/cases", json={"name": "test"}) + + assert err.value.response.status_code == 429 + httpserver_auth.check() + + +def test_client_logs_when_retrying_on_429(httpserver_auth: HTTPServer, mocker): + httpserver_auth.expect_ordered_request( + "/v1/users/user-1", method="GET" + ).respond_with_data("", status=429, headers={"Retry-After": "0"}) + httpserver_auth.expect_ordered_request( + "/v1/users/user-1", method="GET" + ).respond_with_json({"userId": "user-1"}) + + client = Client() + mock_warning = mocker.patch.object(client.settings.logger, "warning") + client.session.get("/v1/users/user-1") + + mock_warning.assert_called_with("Rate limit hit, retrying after: 0 seconds.") + + +def test_incydr_request_retry_strategy_new_preserves_logger(): + mock_logger = MagicMock() + retry = IncydrRequestRetryStrategy( + logger=mock_logger, status=3, status_forcelist=[429] + ) + + cloned = retry.new() + + assert isinstance(cloned, IncydrRequestRetryStrategy) + assert cloned._logger is mock_logger + assert cloned.status == 3 + assert 429 in cloned.status_forcelist diff --git a/tests/test_file_events.py b/tests/test_file_events.py index 26910feb..938aa115 100644 --- a/tests/test_file_events.py +++ b/tests/test_file_events.py @@ -12,6 +12,7 @@ from _incydr_cli.cursor import CursorStore from _incydr_cli.main import incydr from _incydr_sdk.core.client import Client +from _incydr_sdk.core.utils import IncydrRequestRetryStrategy from _incydr_sdk.file_events.models.event import FileEventV2 from _incydr_sdk.file_events.models.response import FileEventsPage from _incydr_sdk.file_events.models.response import SavedSearch @@ -19,6 +20,7 @@ from _incydr_sdk.file_events.models.response import SearchFilterGroup from _incydr_sdk.queries.file_events import EventQuery from _incydr_sdk.queries.file_events import GroupingEventQuery +from tests.conftest import TEST_HOST MICROSECOND_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" @@ -917,6 +919,102 @@ def test_search_raises_exception_when_bad_event_json(httpserver_auth: HTTPServer client.file_events.v2.search(query) +def test_search_mounts_ffs_retry_adapter_on_file_events_path( + httpserver_auth: HTTPServer, +): + event_data = { + "fileEvents": [], + "nextPgToken": None, + "problems": None, + "totalCount": 0, + } + httpserver_auth.expect_request("/v2/file-events", method="POST").respond_with_json( + event_data + ) + + client = Client() + client.file_events.v2.search(EventQuery.model_construct(**TEST_DICT_QUERY)) + + ffs_adapter = client.session.get_adapter(f"{TEST_HOST}/v2/file-events") + base_adapter = client.session.get_adapter(TEST_HOST) + + assert isinstance(ffs_adapter.max_retries, IncydrRequestRetryStrategy) + assert set(ffs_adapter.max_retries.allowed_methods) == {"GET", "POST"} + assert ffs_adapter.max_retries.status == 3 + assert ffs_adapter.max_retries.total is None + assert ffs_adapter.max_retries.connect is False + assert ffs_adapter.max_retries.read is False + assert 429 in ffs_adapter.max_retries.status_forcelist + assert ffs_adapter is not base_adapter + assert isinstance(base_adapter.max_retries, IncydrRequestRetryStrategy) + assert "POST" not in base_adapter.max_retries.allowed_methods + + +def test_search_does_not_mount_ffs_retry_adapter_when_retry_disabled( + httpserver_auth: HTTPServer, +): + event_data = { + "fileEvents": [], + "nextPgToken": None, + "problems": None, + "totalCount": 0, + } + httpserver_auth.expect_request("/v2/file-events", method="POST").respond_with_json( + event_data + ) + + client = Client(retry_on_rate_limit=False) + client.file_events.v2.search(EventQuery.model_construct(**TEST_DICT_QUERY)) + + assert client.file_events.v2._retry_adapter_mounted is False + adapter = client.session.get_adapter(f"{TEST_HOST}/v2/file-events") + assert not isinstance(adapter.max_retries, IncydrRequestRetryStrategy) + assert adapter.max_retries.total == 0 + + +def test_search_retries_post_request_on_429(httpserver_auth: HTTPServer): + event_data = { + "fileEvents": [TEST_EVENT_1], + "nextPgToken": None, + "problems": None, + "totalCount": 1, + } + httpserver_auth.expect_ordered_request( + "/v2/file-events", method="POST" + ).respond_with_data("", status=429, headers={"Retry-After": "0"}) + httpserver_auth.expect_ordered_request( + "/v2/file-events", method="POST" + ).respond_with_json(event_data) + + client = Client() + page = client.file_events.v2.search(EventQuery.model_construct(**TEST_DICT_QUERY)) + + assert isinstance(page, FileEventsPage) + assert page.total_count == 1 + httpserver_auth.check() + + +def test_search_logs_when_retrying_on_429(httpserver_auth: HTTPServer, mocker): + event_data = { + "fileEvents": [], + "nextPgToken": None, + "problems": None, + "totalCount": 0, + } + httpserver_auth.expect_ordered_request( + "/v2/file-events", method="POST" + ).respond_with_data("", status=429, headers={"Retry-After": "0"}) + httpserver_auth.expect_ordered_request( + "/v2/file-events", method="POST" + ).respond_with_json(event_data) + + client = Client() + mock_warning = mocker.patch.object(client.settings.logger, "warning") + client.file_events.v2.search(EventQuery.model_construct(**TEST_DICT_QUERY)) + + mock_warning.assert_called_with("Rate limit hit, retrying after: 0 seconds.") + + # ************************************************ CLI ************************************************ format_arg = pytest.mark.parametrize( From 53f55ac6b4884253f9ac05c56ee41c9756f08fda Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:51:21 -0400 Subject: [PATCH 2/3] pr feedback --- src/_incydr_sdk/core/utils.py | 5 ++- tests/test_core.py | 73 +++++++++++++++++++++++++++++++++++ tests/test_file_events.py | 17 ++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/_incydr_sdk/core/utils.py b/src/_incydr_sdk/core/utils.py index abdbb6e7..01d9c8ba 100644 --- a/src/_incydr_sdk/core/utils.py +++ b/src/_incydr_sdk/core/utils.py @@ -1,5 +1,6 @@ import logging import re +from typing import Optional from requests import Response from urllib3 import Retry @@ -18,7 +19,7 @@ class IncydrRequestRetryStrategy(Retry): request might look like it's hanging when we are retrying due to 429. """ - _logger: logging.Logger = None + _logger: Optional[logging.Logger] = None def __init__(self, *args, logger: logging.Logger = None, **kwargs): self._logger = logger @@ -37,7 +38,7 @@ def get_retry_after(self, response): def get_backoff_time(self): backoff_time = super().get_backoff_time() - if self._logger is not None: + if self._logger is not None and backoff_time > 0: self._logger.warning( f"Rate limit hit, retrying after: {backoff_time} seconds." ) diff --git a/tests/test_core.py b/tests/test_core.py index b6c80458..260079dc 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -5,6 +5,8 @@ from pydantic import Field from pytest_httpserver import HTTPServer from requests.exceptions import HTTPError +from requests.exceptions import RetryError +from urllib3.util.retry import RequestHistory from .conftest import TEST_HOST from .conftest import TEST_TOKEN @@ -328,6 +330,22 @@ def test_client_logs_when_retrying_on_429(httpserver_auth: HTTPServer, mocker): mock_warning.assert_called_with("Rate limit hit, retrying after: 0 seconds.") +def test_client_raises_retry_error_when_429_retries_exhausted( + httpserver_auth: HTTPServer, +): + # status=3 allows 3 retries after the initial request, so 4 total 429 responses. + for _ in range(4): + httpserver_auth.expect_ordered_request( + "/v1/users/user-1", method="GET" + ).respond_with_data("", status=429, headers={"Retry-After": "0"}) + + client = Client() + with pytest.raises(RetryError): + client.session.get("/v1/users/user-1") + + httpserver_auth.check() + + def test_incydr_request_retry_strategy_new_preserves_logger(): mock_logger = MagicMock() retry = IncydrRequestRetryStrategy( @@ -340,3 +358,58 @@ def test_incydr_request_retry_strategy_new_preserves_logger(): assert cloned._logger is mock_logger assert cloned.status == 3 assert 429 in cloned.status_forcelist + + +def test_incydr_request_retry_strategy_does_not_log_zero_backoff(): + mock_logger = MagicMock() + history = ( + RequestHistory( + method="GET", + url="/", + error=None, + status=429, + redirect_location=None, + ), + ) + retry = IncydrRequestRetryStrategy( + logger=mock_logger, + status=3, + status_forcelist=[429], + backoff_factor=5, + history=history, + ) + + assert retry.get_backoff_time() == 0 + mock_logger.warning.assert_not_called() + + +def test_incydr_request_retry_strategy_logs_positive_backoff(): + mock_logger = MagicMock() + history = ( + RequestHistory( + method="GET", + url="/", + error=None, + status=429, + redirect_location=None, + ), + RequestHistory( + method="GET", + url="/", + error=None, + status=429, + redirect_location=None, + ), + ) + retry = IncydrRequestRetryStrategy( + logger=mock_logger, + status=3, + status_forcelist=[429], + backoff_factor=5, + history=history, + ) + + assert retry.get_backoff_time() == 10.0 + mock_logger.warning.assert_called_once_with( + "Rate limit hit, retrying after: 10.0 seconds." + ) diff --git a/tests/test_file_events.py b/tests/test_file_events.py index 938aa115..902fab4a 100644 --- a/tests/test_file_events.py +++ b/tests/test_file_events.py @@ -7,6 +7,7 @@ import pytest from pydantic import ValidationError from pytest_httpserver import HTTPServer +from requests.exceptions import RetryError from _incydr_cli.cmds.options.output_options import TableFormat from _incydr_cli.cursor import CursorStore @@ -1015,6 +1016,22 @@ def test_search_logs_when_retrying_on_429(httpserver_auth: HTTPServer, mocker): mock_warning.assert_called_with("Rate limit hit, retrying after: 0 seconds.") +def test_search_raises_retry_error_when_429_retries_exhausted( + httpserver_auth: HTTPServer, +): + # status=3 allows 3 retries after the initial request, so 4 total 429 responses. + for _ in range(4): + httpserver_auth.expect_ordered_request( + "/v2/file-events", method="POST" + ).respond_with_data("", status=429, headers={"Retry-After": "0"}) + + client = Client() + with pytest.raises(RetryError): + client.file_events.v2.search(EventQuery.model_construct(**TEST_DICT_QUERY)) + + httpserver_auth.check() + + # ************************************************ CLI ************************************************ format_arg = pytest.mark.parametrize( From f689d49d2835fac1b7e493327abb236d372b51a2 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:14:30 -0400 Subject: [PATCH 3/3] version bump --- CHANGELOG.md | 2 +- src/_incydr_sdk/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 658ba33d..45a7bd7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## Unreleased -### Added +### 2.12.3 - 2026-09-11 - The `retry_on_rate_limit` setting, which controls whether the SDK will retry 429 responses. Defaults to on, retrying most 429 responses up to 3 times. Set this to False on SDK initialization if you wish to manually handle retrying 429 responses. When enabled, if retries are exhausted, requests.exceptions.RetryError will be raised. ## 2.12.2 - 2026-06-22 diff --git a/src/_incydr_sdk/__version__.py b/src/_incydr_sdk/__version__.py index 8563b359..674accb5 100644 --- a/src/_incydr_sdk/__version__.py +++ b/src/_incydr_sdk/__version__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2022-present Code42 Software # # SPDX-License-Identifier: MIT -__version__ = "2.12.2" +__version__ = "2.12.3"