Skip to content

Commit 9b220d3

Browse files
Merge pull request #185 from code42/feature/INTEG-2955/retry-429s
INTEG-2955 - retry rate-limited requests
2 parents d7809a1 + f689d49 commit 9b220d3

9 files changed

Lines changed: 369 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
how a consumer would use the library or CLI tool (e.g. adding unit tests, updating documentation, etc) are not captured
1010
here.
1111

12+
## Unreleased
13+
14+
### 2.12.3 - 2026-09-11
15+
- 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.
16+
1217
## 2.12.2 - 2026-06-22
1318

1419
### Added

src/_incydr_cli/core.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import click
55
from requests import HTTPError
6+
from requests.exceptions import RetryError
67

78
from _incydr_cli.exceptions import IncydrCLIException
89
from _incydr_cli.exceptions import LoggedCLIError
@@ -95,7 +96,7 @@ def invoke(self, ctx):
9596
raise IncydrCLIException(err.args[0])
9697
except click.ClickException:
9798
raise
98-
except HTTPError as err:
99+
except (HTTPError, RetryError) as err:
99100
# log error with traceback and print error code with brief error message to console
100101
settings._log_verbose_error(self._original_args, err.request)
101102
raise LoggedCLIError(err.args[0])

src/_incydr_sdk/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# SPDX-FileCopyrightText: 2022-present Code42 Software <integrations@code42.com>
22
#
33
# SPDX-License-Identifier: MIT
4-
__version__ = "2.12.2"
4+
__version__ = "2.12.3"

src/_incydr_sdk/core/client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
from collections import deque
55

6+
from requests.adapters import HTTPAdapter
67
from requests_toolbelt import user_agent
78
from requests_toolbelt.sessions import BaseUrlSession
89

@@ -16,6 +17,7 @@
1617
from _incydr_sdk.core.auth import APIClientAuth
1718
from _incydr_sdk.core.auth import RefreshTokenAuth
1819
from _incydr_sdk.core.settings import IncydrSettings
20+
from _incydr_sdk.core.utils import IncydrRequestRetryStrategy
1921
from _incydr_sdk.customer.client import CustomerClient
2022
from _incydr_sdk.departments.client import DepartmentsClient
2123
from _incydr_sdk.devices.client import DevicesClient
@@ -97,6 +99,25 @@ def response_hook(response, *args, **kwargs):
9799

98100
self._session.hooks["response"] = [response_hook]
99101

102+
if self._settings.retry_on_rate_limit:
103+
rate_limit_retry_strategy = IncydrRequestRetryStrategy(
104+
logger=self._settings.logger,
105+
total=None,
106+
status=3, # retry up to 3 times
107+
connect=False,
108+
read=False,
109+
redirect=False,
110+
other=False, # We do not want to retry on non-status causes.
111+
backoff_factor=5, # if `retry-after` header isn't present, use 5 second exponential backoff
112+
status_forcelist=[
113+
429
114+
], # this only handles 429 errors. Does not retry 5xx.
115+
)
116+
rate_limit_retry_adapter = HTTPAdapter(
117+
max_retries=rate_limit_retry_strategy
118+
)
119+
self._session.mount(self._session.base_url, rate_limit_retry_adapter)
120+
100121
self._actors = ActorsClient(self)
101122
self._agents = AgentsClient(self)
102123
self._alerts = AlertsClient(self)

src/_incydr_sdk/core/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class IncydrSettings(BaseSettings):
7474
* **api_client_id**: `str` The API Client Identifier for authentication. env_var=`INCYDR_API_CLIENT_ID`
7575
* **api_client_secret**: `str` The API Client Secret for authentication. env_var=`INCYDR_API_CLIENT_SECRET`
7676
* **url**: `str` The URL of your Code42 API gateway instance. env_var=`INCYDR_URL`
77+
* **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`
7778
* **page_size**: `int` The default page size for all paginated requests. Defaults to 100. env_var=`INCYDR_PAGE_SIZE`
7879
* **max_response_history**: `int` The maximum number of responses the `incydr.Client.response_history` list will
7980
store. Defaults to 5. env_var=`INCYDR_MAX_RESPONSE_HISTORY`
@@ -91,6 +92,7 @@ class IncydrSettings(BaseSettings):
9192
api_client_id: Optional[str] = Field(default=None)
9293
api_client_secret: Optional[SecretStr] = Field(default=None)
9394
url: str
95+
retry_on_rate_limit: bool = Field(default=True)
9496
page_size: int = Field(default=100)
9597
max_response_history: int = Field(default=5)
9698
use_rich: bool = Field(default=True)

src/_incydr_sdk/core/utils.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import logging
12
import re
3+
from typing import Optional
24

35
from requests import Response
6+
from urllib3 import Retry
47

58

69
def get_filename_from_content_disposition(response: Response, fallback=None) -> str:
@@ -9,3 +12,34 @@ def get_filename_from_content_disposition(response: Response, fallback=None) ->
912
if match:
1013
return match.group(1)
1114
return fallback
15+
16+
17+
class IncydrRequestRetryStrategy(Retry):
18+
"""We subclass :class:`urllib3.Retry` just to add a bit of logging so the user can tell why the
19+
request might look like it's hanging when we are retrying due to 429.
20+
"""
21+
22+
_logger: Optional[logging.Logger] = None
23+
24+
def __init__(self, *args, logger: logging.Logger = None, **kwargs):
25+
self._logger = logger
26+
super().__init__(*args, **kwargs)
27+
28+
def new(self, **kw):
29+
return super().new(logger=self._logger, **kw)
30+
31+
def get_retry_after(self, response):
32+
retry_after = super().get_retry_after(response)
33+
if retry_after is not None and self._logger is not None:
34+
self._logger.warning(
35+
f"Rate limit hit, retrying after: {int(retry_after)} seconds."
36+
)
37+
return retry_after
38+
39+
def get_backoff_time(self):
40+
backoff_time = super().get_backoff_time()
41+
if self._logger is not None and backoff_time > 0:
42+
self._logger.warning(
43+
f"Rate limit hit, retrying after: {backoff_time} seconds."
44+
)
45+
return backoff_time

src/_incydr_sdk/file_events/client.py

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
from pydantic import parse_obj_as
44
from requests import HTTPError
55
from requests.adapters import HTTPAdapter
6-
from urllib3 import Retry
76

87
from ..exceptions import IncydrException
98
from .models.response import FileEventsPage
109
from .models.response import GroupedFileEventResponse
1110
from .models.response import SavedSearch
11+
from _incydr_sdk.core.utils import IncydrRequestRetryStrategy
1212
from _incydr_sdk.queries.file_events import EventQuery
1313
from _incydr_sdk.queries.file_events import GroupingEventQuery
1414

@@ -128,24 +128,36 @@ def get_saved_search(self, search_id: str) -> SavedSearch:
128128

129129
def _mount_retry_adapter(self):
130130
"""Sets custom Retry strategy for FFS url requests to gracefully handle being rate-limited on FFS queries."""
131-
if not self._retry_adapter_mounted:
132-
retry_strategy = FFSQueryRetryStrategy(
131+
if (
132+
not self._retry_adapter_mounted
133+
and self._parent.settings.retry_on_rate_limit
134+
):
135+
retry_strategy = IncydrRequestRetryStrategy(
136+
logger=self._parent.settings.logger,
137+
total=None,
133138
status=3, # retry up to 3 times
139+
connect=False,
140+
read=False,
141+
redirect=False,
142+
other=False, # We do not want to retry on non-status causes.
134143
backoff_factor=5, # if `retry-after` header isn't present, use 5 second exponential backoff
135144
allowed_methods=[
136-
"POST"
137-
], # POST isn't a default allowed method due to it usually modifying resources
145+
"GET",
146+
"POST",
147+
], # POST isn't a default allowed method due to it usually modifying resources.
138148
status_forcelist=[
139149
429
140-
], # this only handles 429 errors, it won't retry on 5xx
150+
], # this only handles 429 errors. Does not retry 5xx.
141151
)
142152
file_event_adapter = HTTPAdapter(
143153
pool_connections=200,
144154
pool_maxsize=4,
145155
pool_block=True,
146156
max_retries=retry_strategy,
147157
)
148-
self._parent.session.mount(self._parent.settings.url, file_event_adapter)
158+
self._parent.session.mount(
159+
f"{self._parent.session.base_url}/v2/file-events", file_event_adapter
160+
)
149161
self._retry_adapter_mounted = True
150162

151163

@@ -159,27 +171,3 @@ def v2(self):
159171
if self._v2 is None:
160172
self._v2 = FileEventsV2(self._parent)
161173
return self._v2
162-
163-
164-
class FFSQueryRetryStrategy(Retry):
165-
"""The forensic search service helpfully responds with a 'retry-after' header, telling us how long until the rate
166-
limiter is reset. We subclass :class:`urllib3.Retry` just to add a bit of logging so the user can tell why the
167-
request might look like it's hanging.
168-
"""
169-
170-
# TODO: Handle debug logging
171-
172-
def get_retry_after(self, response):
173-
retry_after = super().get_retry_after(response)
174-
# if retry_after is not None:
175-
# debug.logger.info(
176-
# f"Forensic search rate limit hit, retrying after: {int(retry_after)} seconds."
177-
# )
178-
return retry_after
179-
180-
def get_backoff_time(self):
181-
backoff_time = super().get_backoff_time()
182-
# debug.logger.info(
183-
# f"Forensic search rate limit hit, retrying after: {backoff_time} seconds."
184-
# )
185-
return backoff_time

0 commit comments

Comments
 (0)