Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

### 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

### Added
Expand Down
3 changes: 2 additions & 1 deletion src/_incydr_cli/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down
2 changes: 1 addition & 1 deletion src/_incydr_sdk/__version__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2022-present Code42 Software <integrations@code42.com>
#
# SPDX-License-Identifier: MIT
__version__ = "2.12.2"
__version__ = "2.12.3"
21 changes: 21 additions & 0 deletions src/_incydr_sdk/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/_incydr_sdk/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions src/_incydr_sdk/core/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import logging
import re
from typing import Optional

from requests import Response
from urllib3 import Retry


def get_filename_from_content_disposition(response: Response, fallback=None) -> str:
Expand All @@ -9,3 +12,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: Optional[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 and backoff_time > 0:
self._logger.warning(
f"Rate limit hit, retrying after: {backoff_time} seconds."
)
return backoff_time
50 changes: 19 additions & 31 deletions src/_incydr_sdk/file_events/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -128,24 +128,36 @@ 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,
pool_maxsize=4,
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


Expand All @@ -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
Loading
Loading