samsara-api 4.2.0__py3-none-any.whl → 4.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- samsara/__init__.py +69 -9
- samsara/addresses/client.py +7 -14
- samsara/addresses/raw_client.py +7 -11
- samsara/alerts/client.py +2 -10
- samsara/alerts/raw_client.py +180 -180
- samsara/assets/__init__.py +3 -3
- samsara/assets/client.py +14 -45
- samsara/assets/raw_client.py +156 -160
- samsara/assets/types/__init__.py +3 -3
- samsara/assets/types/{assets_list_request_type.py → list_assets_request_type.py} +1 -1
- samsara/attributes/client.py +0 -4
- samsara/beta_ap_is/client.py +137 -187
- samsara/beta_ap_is/raw_client.py +2035 -1702
- samsara/carrier_proposed_assignments/client.py +2 -10
- samsara/client.py +5 -0
- samsara/coaching/client.py +2 -22
- samsara/coaching/raw_client.py +108 -108
- samsara/contacts/client.py +2 -8
- samsara/core/__init__.py +5 -0
- samsara/core/client_wrapper.py +18 -10
- samsara/core/custom_pagination.py +152 -0
- samsara/core/http_client.py +176 -90
- samsara/core/http_sse/__init__.py +42 -0
- samsara/core/http_sse/_api.py +112 -0
- samsara/core/http_sse/_decoders.py +61 -0
- samsara/core/http_sse/_exceptions.py +7 -0
- samsara/core/http_sse/_models.py +17 -0
- samsara/core/pagination.py +14 -14
- samsara/core/pydantic_utilities.py +3 -1
- samsara/documents/client.py +2 -12
- samsara/documents/raw_client.py +180 -180
- samsara/driver_qr_codes/raw_client.py +108 -108
- samsara/driver_vehicle_assignments/client.py +0 -12
- samsara/driver_vehicle_assignments/raw_client.py +144 -144
- samsara/drivers/__init__.py +3 -3
- samsara/drivers/client.py +12 -23
- samsara/drivers/raw_client.py +48 -52
- samsara/drivers/types/__init__.py +3 -3
- samsara/drivers/types/{drivers_list_request_driver_activation_status.py → list_drivers_request_driver_activation_status.py} +1 -1
- samsara/equipment/client.py +6 -22
- samsara/errors/bad_gateway_error.py +1 -1
- samsara/errors/gateway_timeout_error.py +1 -1
- samsara/errors/internal_server_error.py +1 -1
- samsara/errors/method_not_allowed_error.py +1 -1
- samsara/errors/not_found_error.py +1 -1
- samsara/errors/not_implemented_error.py +1 -1
- samsara/errors/service_unavailable_error.py +1 -1
- samsara/errors/too_many_requests_error.py +1 -1
- samsara/errors/unauthorized_error.py +1 -1
- samsara/forms/__init__.py +9 -3
- samsara/forms/client.py +17 -10
- samsara/forms/raw_client.py +265 -254
- samsara/forms/types/__init__.py +6 -2
- samsara/forms/types/form_submissions_post_form_submission_request_body_status.py +5 -0
- samsara/fuel_and_energy/client.py +0 -30
- samsara/fuel_and_energy/raw_client.py +180 -180
- samsara/gateways/client.py +2 -6
- samsara/gateways/raw_client.py +108 -108
- samsara/hours_of_service/__init__.py +6 -3
- samsara/hours_of_service/client.py +13 -52
- samsara/hours_of_service/raw_client.py +77 -76
- samsara/hours_of_service/types/__init__.py +4 -2
- samsara/hours_of_service/types/get_hos_daily_logs_request_expand.py +5 -0
- samsara/hubs/client.py +2 -94
- samsara/hubs/raw_client.py +216 -216
- samsara/idling/client.py +0 -18
- samsara/idling/raw_client.py +36 -36
- samsara/ifta/client.py +4 -34
- samsara/ifta/raw_client.py +144 -144
- samsara/industrial/client.py +12 -56
- samsara/industrial/raw_client.py +40 -48
- samsara/issues/client.py +0 -4
- samsara/issues/raw_client.py +108 -108
- samsara/legacy_ap_is/client.py +4 -56
- samsara/legacy_ap_is/raw_client.py +108 -108
- samsara/live_sharing_links/client.py +2 -10
- samsara/live_sharing_links/raw_client.py +144 -144
- samsara/location_and_speed/client.py +2 -20
- samsara/location_and_speed/raw_client.py +36 -36
- samsara/maintenance/__init__.py +3 -3
- samsara/maintenance/client.py +15 -30
- samsara/maintenance/raw_client.py +191 -182
- samsara/maintenance/types/__init__.py +6 -2
- samsara/maintenance/types/create_dvir_request_type.py +5 -0
- samsara/media/client.py +0 -4
- samsara/media/raw_client.py +108 -108
- samsara/messages/client.py +2 -8
- samsara/plans/client.py +0 -21
- samsara/plans/raw_client.py +72 -72
- samsara/preview_ap_is/client.py +0 -140
- samsara/preview_ap_is/raw_client.py +72 -430
- samsara/route_events/client.py +2 -12
- samsara/route_events/raw_client.py +36 -36
- samsara/routes/__init__.py +30 -0
- samsara/routes/client.py +7 -35
- samsara/routes/raw_client.py +257 -256
- samsara/routes/types/__init__.py +34 -0
- samsara/routes/types/get_routes_feed_request_expand.py +5 -0
- samsara/safety/client.py +2 -10
- samsara/safety/raw_client.py +36 -36
- samsara/settings/raw_client.py +180 -180
- samsara/speeding_intervals/raw_client.py +36 -36
- samsara/tachograph_eu_only/client.py +0 -6
- samsara/tags/client.py +2 -8
- samsara/trailer_assignments/client.py +2 -18
- samsara/trailers/client.py +2 -12
- samsara/trailers/raw_client.py +180 -180
- samsara/types/__init__.py +54 -0
- samsara/types/create_routes_stop_request_object_request_body.py +7 -0
- samsara/types/driver_assignment_object_response_body.py +2 -1
- samsara/types/driver_assignment_object_response_body_assignment_type.py +5 -0
- samsara/types/driver_external_ids.py +1 -1
- samsara/types/forms_approval_config_object_response_body.py +2 -1
- samsara/types/forms_approval_config_object_response_body_type.py +5 -0
- samsara/types/fuel_level_trigger_details_object_request_body.py +2 -1
- samsara/types/fuel_level_trigger_details_object_request_body_operation.py +5 -0
- samsara/types/fuel_level_trigger_details_object_response_body.py +4 -1
- samsara/types/fuel_level_trigger_details_object_response_body_operation.py +5 -0
- samsara/types/idling_event_object_v_20251023_response_body.py +4 -3
- samsara/types/idling_event_object_v_20251023_response_body_pto_state.py +5 -0
- samsara/types/patch_issue_request_body_assigned_to_request_body.py +2 -1
- samsara/types/patch_issue_request_body_assigned_to_request_body_type.py +5 -0
- samsara/types/reading_datapoint_request_body.py +5 -4
- samsara/types/reading_datapoint_request_body_entity_type.py +5 -0
- samsara/types/reading_history_response_body.py +1 -1
- samsara/types/reading_snapshot_response_body.py +1 -1
- samsara/types/resolved_by.py +2 -1
- samsara/types/resolved_by_type.py +5 -0
- samsara/types/route_feed_object_response_body.py +2 -1
- samsara/types/route_feed_object_response_body_type.py +5 -0
- samsara/types/route_settings_request_body.py +8 -0
- samsara/types/route_settings_request_body_sequencing_method.py +7 -0
- samsara/types/route_settings_response_body.py +8 -0
- samsara/types/route_settings_response_body_sequencing_method.py +7 -0
- samsara/types/route_stop_details_object_response_body.py +2 -1
- samsara/types/route_stop_details_object_response_body_type.py +5 -0
- samsara/types/routes_stop_response_object_response_body.py +7 -0
- samsara/types/training_learner_object_response_body.py +2 -1
- samsara/types/training_learner_object_response_body_type.py +5 -0
- samsara/types/update_routes_stop_request_object_request_body.py +7 -0
- samsara/types/vehicle_assignment_object_response_body.py +4 -3
- samsara/types/vehicle_assignment_object_response_body_assignment_type.py +5 -0
- samsara/types/vehicle_external_ids.py +1 -1
- samsara/types/work_order_money_object_request_body.py +2 -1
- samsara/types/work_order_money_object_request_body_currency.py +5 -0
- samsara/types/work_order_money_object_response_body.py +2 -1
- samsara/types/work_order_money_object_response_body_currency.py +5 -0
- samsara/users/client.py +4 -16
- samsara/vehicle_locations/client.py +4 -16
- samsara/vehicles/client.py +7 -22
- samsara/vehicles/raw_client.py +43 -47
- samsara/webhooks/client.py +2 -10
- samsara/webhooks/raw_client.py +180 -180
- samsara/work_orders/client.py +4 -18
- samsara/work_orders/raw_client.py +216 -216
- {samsara_api-4.2.0.dist-info → samsara_api-4.3.0.dist-info}/METADATA +2 -1
- {samsara_api-4.2.0.dist-info → samsara_api-4.3.0.dist-info}/RECORD +159 -132
- {samsara_api-4.2.0.dist-info → samsara_api-4.3.0.dist-info}/LICENSE +0 -0
- {samsara_api-4.2.0.dist-info → samsara_api-4.3.0.dist-info}/WHEEL +0 -0
samsara/core/__init__.py
CHANGED
|
@@ -8,6 +8,7 @@ from importlib import import_module
|
|
|
8
8
|
if typing.TYPE_CHECKING:
|
|
9
9
|
from .api_error import ApiError
|
|
10
10
|
from .client_wrapper import AsyncClientWrapper, BaseClientWrapper, SyncClientWrapper
|
|
11
|
+
from .custom_pagination import AsyncCustomPager, SyncCustomPager
|
|
11
12
|
from .datetime_utils import serialize_datetime
|
|
12
13
|
from .file import File, convert_file_dict_to_httpx_tuples, with_content_type
|
|
13
14
|
from .http_client import AsyncHttpClient, HttpClient
|
|
@@ -30,6 +31,7 @@ if typing.TYPE_CHECKING:
|
|
|
30
31
|
_dynamic_imports: typing.Dict[str, str] = {
|
|
31
32
|
"ApiError": ".api_error",
|
|
32
33
|
"AsyncClientWrapper": ".client_wrapper",
|
|
34
|
+
"AsyncCustomPager": ".custom_pagination",
|
|
33
35
|
"AsyncHttpClient": ".http_client",
|
|
34
36
|
"AsyncHttpResponse": ".http_response",
|
|
35
37
|
"AsyncPager": ".pagination",
|
|
@@ -41,6 +43,7 @@ _dynamic_imports: typing.Dict[str, str] = {
|
|
|
41
43
|
"IS_PYDANTIC_V2": ".pydantic_utilities",
|
|
42
44
|
"RequestOptions": ".request_options",
|
|
43
45
|
"SyncClientWrapper": ".client_wrapper",
|
|
46
|
+
"SyncCustomPager": ".custom_pagination",
|
|
44
47
|
"SyncPager": ".pagination",
|
|
45
48
|
"UniversalBaseModel": ".pydantic_utilities",
|
|
46
49
|
"UniversalRootModel": ".pydantic_utilities",
|
|
@@ -82,6 +85,7 @@ def __dir__():
|
|
|
82
85
|
__all__ = [
|
|
83
86
|
"ApiError",
|
|
84
87
|
"AsyncClientWrapper",
|
|
88
|
+
"AsyncCustomPager",
|
|
85
89
|
"AsyncHttpClient",
|
|
86
90
|
"AsyncHttpResponse",
|
|
87
91
|
"AsyncPager",
|
|
@@ -93,6 +97,7 @@ __all__ = [
|
|
|
93
97
|
"IS_PYDANTIC_V2",
|
|
94
98
|
"RequestOptions",
|
|
95
99
|
"SyncClientWrapper",
|
|
100
|
+
"SyncCustomPager",
|
|
96
101
|
"SyncPager",
|
|
97
102
|
"UniversalBaseModel",
|
|
98
103
|
"UniversalRootModel",
|
samsara/core/client_wrapper.py
CHANGED
|
@@ -10,7 +10,7 @@ class BaseClientWrapper:
|
|
|
10
10
|
def __init__(
|
|
11
11
|
self,
|
|
12
12
|
*,
|
|
13
|
-
token: typing.
|
|
13
|
+
token: typing.Union[str, typing.Callable[[], str]],
|
|
14
14
|
headers: typing.Optional[typing.Dict[str, str]] = None,
|
|
15
15
|
base_url: str,
|
|
16
16
|
timeout: typing.Optional[float] = None,
|
|
@@ -22,19 +22,17 @@ class BaseClientWrapper:
|
|
|
22
22
|
|
|
23
23
|
def get_headers(self) -> typing.Dict[str, str]:
|
|
24
24
|
headers: typing.Dict[str, str] = {
|
|
25
|
-
"User-Agent": "samsara-api/4.
|
|
25
|
+
"User-Agent": "samsara-api/4.3.0",
|
|
26
26
|
"X-Fern-Language": "Python",
|
|
27
27
|
"X-Fern-SDK-Name": "samsara-api",
|
|
28
|
-
"X-Fern-SDK-Version": "4.
|
|
28
|
+
"X-Fern-SDK-Version": "4.3.0",
|
|
29
29
|
**(self.get_custom_headers() or {}),
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
if token is not None:
|
|
33
|
-
headers["Authorization"] = f"Bearer {token}"
|
|
31
|
+
headers["Authorization"] = f"Bearer {self._get_token()}"
|
|
34
32
|
return headers
|
|
35
33
|
|
|
36
|
-
def _get_token(self) ->
|
|
37
|
-
if isinstance(self._token, str)
|
|
34
|
+
def _get_token(self) -> str:
|
|
35
|
+
if isinstance(self._token, str):
|
|
38
36
|
return self._token
|
|
39
37
|
else:
|
|
40
38
|
return self._token()
|
|
@@ -53,7 +51,7 @@ class SyncClientWrapper(BaseClientWrapper):
|
|
|
53
51
|
def __init__(
|
|
54
52
|
self,
|
|
55
53
|
*,
|
|
56
|
-
token: typing.
|
|
54
|
+
token: typing.Union[str, typing.Callable[[], str]],
|
|
57
55
|
headers: typing.Optional[typing.Dict[str, str]] = None,
|
|
58
56
|
base_url: str,
|
|
59
57
|
timeout: typing.Optional[float] = None,
|
|
@@ -72,16 +70,26 @@ class AsyncClientWrapper(BaseClientWrapper):
|
|
|
72
70
|
def __init__(
|
|
73
71
|
self,
|
|
74
72
|
*,
|
|
75
|
-
token: typing.
|
|
73
|
+
token: typing.Union[str, typing.Callable[[], str]],
|
|
76
74
|
headers: typing.Optional[typing.Dict[str, str]] = None,
|
|
77
75
|
base_url: str,
|
|
78
76
|
timeout: typing.Optional[float] = None,
|
|
77
|
+
async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None,
|
|
79
78
|
httpx_client: httpx.AsyncClient,
|
|
80
79
|
):
|
|
81
80
|
super().__init__(token=token, headers=headers, base_url=base_url, timeout=timeout)
|
|
81
|
+
self._async_token = async_token
|
|
82
82
|
self.httpx_client = AsyncHttpClient(
|
|
83
83
|
httpx_client=httpx_client,
|
|
84
84
|
base_headers=self.get_headers,
|
|
85
85
|
base_timeout=self.get_timeout,
|
|
86
86
|
base_url=self.get_base_url,
|
|
87
|
+
async_base_headers=self.async_get_headers,
|
|
87
88
|
)
|
|
89
|
+
|
|
90
|
+
async def async_get_headers(self) -> typing.Dict[str, str]:
|
|
91
|
+
headers = self.get_headers()
|
|
92
|
+
if self._async_token is not None:
|
|
93
|
+
token = await self._async_token()
|
|
94
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
95
|
+
return headers
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# This file was auto-generated by Fern from our API Definition.
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Custom Pagination Support
|
|
5
|
+
|
|
6
|
+
This file is designed to be modified by SDK users to implement their own
|
|
7
|
+
pagination logic. The generator will import SyncCustomPager and AsyncCustomPager
|
|
8
|
+
from this module when custom pagination is used.
|
|
9
|
+
|
|
10
|
+
Users should:
|
|
11
|
+
1. Implement their custom pager (e.g., PayrocPager, MyCustomPager, etc.)
|
|
12
|
+
2. Create adapter classes (SyncCustomPager/AsyncCustomPager) that bridge
|
|
13
|
+
between the generated SDK code and their custom pager implementation
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, AsyncIterator, Generic, Iterator, TypeVar
|
|
19
|
+
|
|
20
|
+
# Import the base utilities you'll need
|
|
21
|
+
# Adjust these imports based on your actual structure
|
|
22
|
+
try:
|
|
23
|
+
from .client_wrapper import AsyncClientWrapper, SyncClientWrapper
|
|
24
|
+
except ImportError:
|
|
25
|
+
# Fallback for type hints
|
|
26
|
+
AsyncClientWrapper = Any # type: ignore
|
|
27
|
+
SyncClientWrapper = Any # type: ignore
|
|
28
|
+
|
|
29
|
+
TItem = TypeVar("TItem")
|
|
30
|
+
TResponse = TypeVar("TResponse")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SyncCustomPager(Generic[TItem, TResponse]):
|
|
34
|
+
"""
|
|
35
|
+
Adapter for custom synchronous pagination.
|
|
36
|
+
|
|
37
|
+
The generator will call this with:
|
|
38
|
+
SyncCustomPager(initial_response=response, client_wrapper=client_wrapper)
|
|
39
|
+
|
|
40
|
+
Implement this class to extract pagination metadata from your response
|
|
41
|
+
and delegate to your custom pager implementation.
|
|
42
|
+
|
|
43
|
+
Example implementation:
|
|
44
|
+
|
|
45
|
+
class SyncCustomPager(Generic[TItem, TResponse]):
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
initial_response: TResponse,
|
|
50
|
+
client_wrapper: SyncClientWrapper,
|
|
51
|
+
):
|
|
52
|
+
# Extract data and pagination metadata from response
|
|
53
|
+
data = initial_response.data # Adjust based on your response structure
|
|
54
|
+
links = initial_response.links
|
|
55
|
+
|
|
56
|
+
# Initialize your custom pager
|
|
57
|
+
self._pager = MyCustomPager(
|
|
58
|
+
current_page=Page(data),
|
|
59
|
+
httpx_client=client_wrapper.httpx_client,
|
|
60
|
+
get_headers=client_wrapper.get_headers,
|
|
61
|
+
# ... other parameters
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __iter__(self):
|
|
65
|
+
return iter(self._pager)
|
|
66
|
+
|
|
67
|
+
# Delegate other methods to your pager...
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
initial_response: TResponse,
|
|
74
|
+
client_wrapper: SyncClientWrapper,
|
|
75
|
+
):
|
|
76
|
+
"""
|
|
77
|
+
Initialize the custom pager.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
initial_response: The parsed API response from the first request
|
|
81
|
+
client_wrapper: The client wrapper providing HTTP client and utilities
|
|
82
|
+
"""
|
|
83
|
+
raise NotImplementedError(
|
|
84
|
+
"SyncCustomPager must be implemented. "
|
|
85
|
+
"Please implement this class in core/custom_pagination.py to define your pagination logic. "
|
|
86
|
+
"See the class docstring for examples."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def __iter__(self) -> Iterator[TItem]:
|
|
90
|
+
"""Iterate through all items across all pages."""
|
|
91
|
+
raise NotImplementedError("Must implement __iter__ method")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AsyncCustomPager(Generic[TItem, TResponse]):
|
|
95
|
+
"""
|
|
96
|
+
Adapter for custom asynchronous pagination.
|
|
97
|
+
|
|
98
|
+
The generator will call this with:
|
|
99
|
+
AsyncCustomPager(initial_response=response, client_wrapper=client_wrapper)
|
|
100
|
+
|
|
101
|
+
Implement this class to extract pagination metadata from your response
|
|
102
|
+
and delegate to your custom async pager implementation.
|
|
103
|
+
|
|
104
|
+
Example implementation:
|
|
105
|
+
|
|
106
|
+
class AsyncCustomPager(Generic[TItem, TResponse]):
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
*,
|
|
110
|
+
initial_response: TResponse,
|
|
111
|
+
client_wrapper: AsyncClientWrapper,
|
|
112
|
+
):
|
|
113
|
+
# Extract data and pagination metadata from response
|
|
114
|
+
data = initial_response.data # Adjust based on your response structure
|
|
115
|
+
links = initial_response.links
|
|
116
|
+
|
|
117
|
+
# Initialize your custom async pager
|
|
118
|
+
self._pager = MyAsyncCustomPager(
|
|
119
|
+
current_page=Page(data),
|
|
120
|
+
httpx_client=client_wrapper.httpx_client,
|
|
121
|
+
get_headers=client_wrapper.get_headers,
|
|
122
|
+
# ... other parameters
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def __aiter__(self):
|
|
126
|
+
return self._pager.__aiter__()
|
|
127
|
+
|
|
128
|
+
# Delegate other methods to your pager...
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
*,
|
|
134
|
+
initial_response: TResponse,
|
|
135
|
+
client_wrapper: AsyncClientWrapper,
|
|
136
|
+
):
|
|
137
|
+
"""
|
|
138
|
+
Initialize the custom async pager.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
initial_response: The parsed API response from the first request
|
|
142
|
+
client_wrapper: The client wrapper providing HTTP client and utilities
|
|
143
|
+
"""
|
|
144
|
+
raise NotImplementedError(
|
|
145
|
+
"AsyncCustomPager must be implemented. "
|
|
146
|
+
"Please implement this class in core/custom_pagination.py to define your pagination logic. "
|
|
147
|
+
"See the class docstring for examples."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def __aiter__(self) -> AsyncIterator[TItem]:
|
|
151
|
+
"""Asynchronously iterate through all items across all pages."""
|
|
152
|
+
raise NotImplementedError("Must implement __aiter__ method")
|
samsara/core/http_client.py
CHANGED
|
@@ -14,13 +14,13 @@ from .file import File, convert_file_dict_to_httpx_tuples
|
|
|
14
14
|
from .force_multipart import FORCE_MULTIPART
|
|
15
15
|
from .jsonable_encoder import jsonable_encoder
|
|
16
16
|
from .query_encoder import encode_query
|
|
17
|
-
from .remove_none_from_dict import remove_none_from_dict
|
|
17
|
+
from .remove_none_from_dict import remove_none_from_dict as remove_none_from_dict
|
|
18
18
|
from .request_options import RequestOptions
|
|
19
19
|
from httpx._types import RequestFiles
|
|
20
20
|
|
|
21
|
-
INITIAL_RETRY_DELAY_SECONDS = 0
|
|
22
|
-
MAX_RETRY_DELAY_SECONDS =
|
|
23
|
-
|
|
21
|
+
INITIAL_RETRY_DELAY_SECONDS = 1.0
|
|
22
|
+
MAX_RETRY_DELAY_SECONDS = 60.0
|
|
23
|
+
JITTER_FACTOR = 0.2 # 20% random jitter
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float]:
|
|
@@ -64,6 +64,38 @@ def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float
|
|
|
64
64
|
return seconds
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def _add_positive_jitter(delay: float) -> float:
|
|
68
|
+
"""Add positive jitter (0-20%) to prevent thundering herd."""
|
|
69
|
+
jitter_multiplier = 1 + random() * JITTER_FACTOR
|
|
70
|
+
return delay * jitter_multiplier
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _add_symmetric_jitter(delay: float) -> float:
|
|
74
|
+
"""Add symmetric jitter (±10%) for exponential backoff."""
|
|
75
|
+
jitter_multiplier = 1 + (random() - 0.5) * JITTER_FACTOR
|
|
76
|
+
return delay * jitter_multiplier
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_x_ratelimit_reset(response_headers: httpx.Headers) -> typing.Optional[float]:
|
|
80
|
+
"""
|
|
81
|
+
Parse the X-RateLimit-Reset header (Unix timestamp in seconds).
|
|
82
|
+
Returns seconds to wait, or None if header is missing/invalid.
|
|
83
|
+
"""
|
|
84
|
+
reset_time_str = response_headers.get("x-ratelimit-reset")
|
|
85
|
+
if reset_time_str is None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
reset_time = int(reset_time_str)
|
|
90
|
+
delay = reset_time - time.time()
|
|
91
|
+
if delay > 0:
|
|
92
|
+
return delay
|
|
93
|
+
except (ValueError, TypeError):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
67
99
|
def _retry_timeout(response: httpx.Response, retries: int) -> float:
|
|
68
100
|
"""
|
|
69
101
|
Determine the amount of time to wait before retrying a request.
|
|
@@ -71,17 +103,19 @@ def _retry_timeout(response: httpx.Response, retries: int) -> float:
|
|
|
71
103
|
with a jitter to determine the number of seconds to wait.
|
|
72
104
|
"""
|
|
73
105
|
|
|
74
|
-
#
|
|
106
|
+
# 1. Check Retry-After header first
|
|
75
107
|
retry_after = _parse_retry_after(response.headers)
|
|
76
|
-
if retry_after is not None and retry_after
|
|
77
|
-
return retry_after
|
|
108
|
+
if retry_after is not None and retry_after > 0:
|
|
109
|
+
return min(retry_after, MAX_RETRY_DELAY_SECONDS)
|
|
78
110
|
|
|
79
|
-
#
|
|
80
|
-
|
|
111
|
+
# 2. Check X-RateLimit-Reset header (with positive jitter)
|
|
112
|
+
ratelimit_reset = _parse_x_ratelimit_reset(response.headers)
|
|
113
|
+
if ratelimit_reset is not None:
|
|
114
|
+
return _add_positive_jitter(min(ratelimit_reset, MAX_RETRY_DELAY_SECONDS))
|
|
81
115
|
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
return
|
|
116
|
+
# 3. Fall back to exponential backoff (with symmetric jitter)
|
|
117
|
+
backoff = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS)
|
|
118
|
+
return _add_symmetric_jitter(backoff)
|
|
85
119
|
|
|
86
120
|
|
|
87
121
|
def _should_retry(response: httpx.Response) -> bool:
|
|
@@ -89,6 +123,21 @@ def _should_retry(response: httpx.Response) -> bool:
|
|
|
89
123
|
return response.status_code >= 500 or response.status_code in retryable_400s
|
|
90
124
|
|
|
91
125
|
|
|
126
|
+
def _maybe_filter_none_from_multipart_data(
|
|
127
|
+
data: typing.Optional[typing.Any],
|
|
128
|
+
request_files: typing.Optional[RequestFiles],
|
|
129
|
+
force_multipart: typing.Optional[bool],
|
|
130
|
+
) -> typing.Optional[typing.Any]:
|
|
131
|
+
"""
|
|
132
|
+
Filter None values from data body for multipart/form requests.
|
|
133
|
+
This prevents httpx from converting None to empty strings in multipart encoding.
|
|
134
|
+
Only applies when files are present or force_multipart is True.
|
|
135
|
+
"""
|
|
136
|
+
if data is not None and isinstance(data, typing.Mapping) and (request_files or force_multipart):
|
|
137
|
+
return remove_none_from_dict(data)
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
92
141
|
def remove_omit_from_dict(
|
|
93
142
|
original: typing.Dict[str, typing.Optional[typing.Any]],
|
|
94
143
|
omit: typing.Optional[typing.Any],
|
|
@@ -188,7 +237,7 @@ class HttpClient:
|
|
|
188
237
|
] = None,
|
|
189
238
|
headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
190
239
|
request_options: typing.Optional[RequestOptions] = None,
|
|
191
|
-
retries: int =
|
|
240
|
+
retries: int = 0,
|
|
192
241
|
omit: typing.Optional[typing.Any] = None,
|
|
193
242
|
force_multipart: typing.Optional[bool] = None,
|
|
194
243
|
) -> httpx.Response:
|
|
@@ -210,6 +259,28 @@ class HttpClient:
|
|
|
210
259
|
if (request_files is None or len(request_files) == 0) and force_multipart:
|
|
211
260
|
request_files = FORCE_MULTIPART
|
|
212
261
|
|
|
262
|
+
data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)
|
|
263
|
+
|
|
264
|
+
# Compute encoded params separately to avoid passing empty list to httpx
|
|
265
|
+
# (httpx strips existing query params from URL when params=[] is passed)
|
|
266
|
+
_encoded_params = encode_query(
|
|
267
|
+
jsonable_encoder(
|
|
268
|
+
remove_none_from_dict(
|
|
269
|
+
remove_omit_from_dict(
|
|
270
|
+
{
|
|
271
|
+
**(params if params is not None else {}),
|
|
272
|
+
**(
|
|
273
|
+
request_options.get("additional_query_parameters", {}) or {}
|
|
274
|
+
if request_options is not None
|
|
275
|
+
else {}
|
|
276
|
+
),
|
|
277
|
+
},
|
|
278
|
+
omit,
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
213
284
|
response = self.httpx_client.request(
|
|
214
285
|
method=method,
|
|
215
286
|
url=urllib.parse.urljoin(f"{base_url}/", path),
|
|
@@ -222,23 +293,7 @@ class HttpClient:
|
|
|
222
293
|
}
|
|
223
294
|
)
|
|
224
295
|
),
|
|
225
|
-
params=
|
|
226
|
-
jsonable_encoder(
|
|
227
|
-
remove_none_from_dict(
|
|
228
|
-
remove_omit_from_dict(
|
|
229
|
-
{
|
|
230
|
-
**(params if params is not None else {}),
|
|
231
|
-
**(
|
|
232
|
-
request_options.get("additional_query_parameters", {}) or {}
|
|
233
|
-
if request_options is not None
|
|
234
|
-
else {}
|
|
235
|
-
),
|
|
236
|
-
},
|
|
237
|
-
omit,
|
|
238
|
-
)
|
|
239
|
-
)
|
|
240
|
-
)
|
|
241
|
-
),
|
|
296
|
+
params=_encoded_params if _encoded_params else None,
|
|
242
297
|
json=json_body,
|
|
243
298
|
data=data_body,
|
|
244
299
|
content=content,
|
|
@@ -246,9 +301,9 @@ class HttpClient:
|
|
|
246
301
|
timeout=timeout,
|
|
247
302
|
)
|
|
248
303
|
|
|
249
|
-
max_retries: int = request_options.get("max_retries",
|
|
304
|
+
max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2
|
|
250
305
|
if _should_retry(response=response):
|
|
251
|
-
if
|
|
306
|
+
if retries < max_retries:
|
|
252
307
|
time.sleep(_retry_timeout(response=response, retries=retries))
|
|
253
308
|
return self.request(
|
|
254
309
|
path=path,
|
|
@@ -285,7 +340,7 @@ class HttpClient:
|
|
|
285
340
|
] = None,
|
|
286
341
|
headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
287
342
|
request_options: typing.Optional[RequestOptions] = None,
|
|
288
|
-
retries: int =
|
|
343
|
+
retries: int = 0,
|
|
289
344
|
omit: typing.Optional[typing.Any] = None,
|
|
290
345
|
force_multipart: typing.Optional[bool] = None,
|
|
291
346
|
) -> typing.Iterator[httpx.Response]:
|
|
@@ -307,6 +362,28 @@ class HttpClient:
|
|
|
307
362
|
|
|
308
363
|
json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit)
|
|
309
364
|
|
|
365
|
+
data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)
|
|
366
|
+
|
|
367
|
+
# Compute encoded params separately to avoid passing empty list to httpx
|
|
368
|
+
# (httpx strips existing query params from URL when params=[] is passed)
|
|
369
|
+
_encoded_params = encode_query(
|
|
370
|
+
jsonable_encoder(
|
|
371
|
+
remove_none_from_dict(
|
|
372
|
+
remove_omit_from_dict(
|
|
373
|
+
{
|
|
374
|
+
**(params if params is not None else {}),
|
|
375
|
+
**(
|
|
376
|
+
request_options.get("additional_query_parameters", {})
|
|
377
|
+
if request_options is not None
|
|
378
|
+
else {}
|
|
379
|
+
),
|
|
380
|
+
},
|
|
381
|
+
omit,
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
310
387
|
with self.httpx_client.stream(
|
|
311
388
|
method=method,
|
|
312
389
|
url=urllib.parse.urljoin(f"{base_url}/", path),
|
|
@@ -319,23 +396,7 @@ class HttpClient:
|
|
|
319
396
|
}
|
|
320
397
|
)
|
|
321
398
|
),
|
|
322
|
-
params=
|
|
323
|
-
jsonable_encoder(
|
|
324
|
-
remove_none_from_dict(
|
|
325
|
-
remove_omit_from_dict(
|
|
326
|
-
{
|
|
327
|
-
**(params if params is not None else {}),
|
|
328
|
-
**(
|
|
329
|
-
request_options.get("additional_query_parameters", {})
|
|
330
|
-
if request_options is not None
|
|
331
|
-
else {}
|
|
332
|
-
),
|
|
333
|
-
},
|
|
334
|
-
omit,
|
|
335
|
-
)
|
|
336
|
-
)
|
|
337
|
-
)
|
|
338
|
-
),
|
|
399
|
+
params=_encoded_params if _encoded_params else None,
|
|
339
400
|
json=json_body,
|
|
340
401
|
data=data_body,
|
|
341
402
|
content=content,
|
|
@@ -353,12 +414,19 @@ class AsyncHttpClient:
|
|
|
353
414
|
base_timeout: typing.Callable[[], typing.Optional[float]],
|
|
354
415
|
base_headers: typing.Callable[[], typing.Dict[str, str]],
|
|
355
416
|
base_url: typing.Optional[typing.Callable[[], str]] = None,
|
|
417
|
+
async_base_headers: typing.Optional[typing.Callable[[], typing.Awaitable[typing.Dict[str, str]]]] = None,
|
|
356
418
|
):
|
|
357
419
|
self.base_url = base_url
|
|
358
420
|
self.base_timeout = base_timeout
|
|
359
421
|
self.base_headers = base_headers
|
|
422
|
+
self.async_base_headers = async_base_headers
|
|
360
423
|
self.httpx_client = httpx_client
|
|
361
424
|
|
|
425
|
+
async def _get_headers(self) -> typing.Dict[str, str]:
|
|
426
|
+
if self.async_base_headers is not None:
|
|
427
|
+
return await self.async_base_headers()
|
|
428
|
+
return self.base_headers()
|
|
429
|
+
|
|
362
430
|
def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str:
|
|
363
431
|
base_url = maybe_base_url
|
|
364
432
|
if self.base_url is not None and base_url is None:
|
|
@@ -386,7 +454,7 @@ class AsyncHttpClient:
|
|
|
386
454
|
] = None,
|
|
387
455
|
headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
388
456
|
request_options: typing.Optional[RequestOptions] = None,
|
|
389
|
-
retries: int =
|
|
457
|
+
retries: int = 0,
|
|
390
458
|
omit: typing.Optional[typing.Any] = None,
|
|
391
459
|
force_multipart: typing.Optional[bool] = None,
|
|
392
460
|
) -> httpx.Response:
|
|
@@ -408,6 +476,31 @@ class AsyncHttpClient:
|
|
|
408
476
|
|
|
409
477
|
json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit)
|
|
410
478
|
|
|
479
|
+
data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)
|
|
480
|
+
|
|
481
|
+
# Get headers (supports async token providers)
|
|
482
|
+
_headers = await self._get_headers()
|
|
483
|
+
|
|
484
|
+
# Compute encoded params separately to avoid passing empty list to httpx
|
|
485
|
+
# (httpx strips existing query params from URL when params=[] is passed)
|
|
486
|
+
_encoded_params = encode_query(
|
|
487
|
+
jsonable_encoder(
|
|
488
|
+
remove_none_from_dict(
|
|
489
|
+
remove_omit_from_dict(
|
|
490
|
+
{
|
|
491
|
+
**(params if params is not None else {}),
|
|
492
|
+
**(
|
|
493
|
+
request_options.get("additional_query_parameters", {}) or {}
|
|
494
|
+
if request_options is not None
|
|
495
|
+
else {}
|
|
496
|
+
),
|
|
497
|
+
},
|
|
498
|
+
omit,
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
|
|
411
504
|
# Add the input to each of these and do None-safety checks
|
|
412
505
|
response = await self.httpx_client.request(
|
|
413
506
|
method=method,
|
|
@@ -415,29 +508,13 @@ class AsyncHttpClient:
|
|
|
415
508
|
headers=jsonable_encoder(
|
|
416
509
|
remove_none_from_dict(
|
|
417
510
|
{
|
|
418
|
-
**
|
|
511
|
+
**_headers,
|
|
419
512
|
**(headers if headers is not None else {}),
|
|
420
513
|
**(request_options.get("additional_headers", {}) or {} if request_options is not None else {}),
|
|
421
514
|
}
|
|
422
515
|
)
|
|
423
516
|
),
|
|
424
|
-
params=
|
|
425
|
-
jsonable_encoder(
|
|
426
|
-
remove_none_from_dict(
|
|
427
|
-
remove_omit_from_dict(
|
|
428
|
-
{
|
|
429
|
-
**(params if params is not None else {}),
|
|
430
|
-
**(
|
|
431
|
-
request_options.get("additional_query_parameters", {}) or {}
|
|
432
|
-
if request_options is not None
|
|
433
|
-
else {}
|
|
434
|
-
),
|
|
435
|
-
},
|
|
436
|
-
omit,
|
|
437
|
-
)
|
|
438
|
-
)
|
|
439
|
-
)
|
|
440
|
-
),
|
|
517
|
+
params=_encoded_params if _encoded_params else None,
|
|
441
518
|
json=json_body,
|
|
442
519
|
data=data_body,
|
|
443
520
|
content=content,
|
|
@@ -445,9 +522,9 @@ class AsyncHttpClient:
|
|
|
445
522
|
timeout=timeout,
|
|
446
523
|
)
|
|
447
524
|
|
|
448
|
-
max_retries: int = request_options.get("max_retries",
|
|
525
|
+
max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2
|
|
449
526
|
if _should_retry(response=response):
|
|
450
|
-
if
|
|
527
|
+
if retries < max_retries:
|
|
451
528
|
await asyncio.sleep(_retry_timeout(response=response, retries=retries))
|
|
452
529
|
return await self.request(
|
|
453
530
|
path=path,
|
|
@@ -483,7 +560,7 @@ class AsyncHttpClient:
|
|
|
483
560
|
] = None,
|
|
484
561
|
headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
485
562
|
request_options: typing.Optional[RequestOptions] = None,
|
|
486
|
-
retries: int =
|
|
563
|
+
retries: int = 0,
|
|
487
564
|
omit: typing.Optional[typing.Any] = None,
|
|
488
565
|
force_multipart: typing.Optional[bool] = None,
|
|
489
566
|
) -> typing.AsyncIterator[httpx.Response]:
|
|
@@ -505,35 +582,44 @@ class AsyncHttpClient:
|
|
|
505
582
|
|
|
506
583
|
json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit)
|
|
507
584
|
|
|
585
|
+
data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)
|
|
586
|
+
|
|
587
|
+
# Get headers (supports async token providers)
|
|
588
|
+
_headers = await self._get_headers()
|
|
589
|
+
|
|
590
|
+
# Compute encoded params separately to avoid passing empty list to httpx
|
|
591
|
+
# (httpx strips existing query params from URL when params=[] is passed)
|
|
592
|
+
_encoded_params = encode_query(
|
|
593
|
+
jsonable_encoder(
|
|
594
|
+
remove_none_from_dict(
|
|
595
|
+
remove_omit_from_dict(
|
|
596
|
+
{
|
|
597
|
+
**(params if params is not None else {}),
|
|
598
|
+
**(
|
|
599
|
+
request_options.get("additional_query_parameters", {})
|
|
600
|
+
if request_options is not None
|
|
601
|
+
else {}
|
|
602
|
+
),
|
|
603
|
+
},
|
|
604
|
+
omit=omit,
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
|
|
508
610
|
async with self.httpx_client.stream(
|
|
509
611
|
method=method,
|
|
510
612
|
url=urllib.parse.urljoin(f"{base_url}/", path),
|
|
511
613
|
headers=jsonable_encoder(
|
|
512
614
|
remove_none_from_dict(
|
|
513
615
|
{
|
|
514
|
-
**
|
|
616
|
+
**_headers,
|
|
515
617
|
**(headers if headers is not None else {}),
|
|
516
618
|
**(request_options.get("additional_headers", {}) if request_options is not None else {}),
|
|
517
619
|
}
|
|
518
620
|
)
|
|
519
621
|
),
|
|
520
|
-
params=
|
|
521
|
-
jsonable_encoder(
|
|
522
|
-
remove_none_from_dict(
|
|
523
|
-
remove_omit_from_dict(
|
|
524
|
-
{
|
|
525
|
-
**(params if params is not None else {}),
|
|
526
|
-
**(
|
|
527
|
-
request_options.get("additional_query_parameters", {})
|
|
528
|
-
if request_options is not None
|
|
529
|
-
else {}
|
|
530
|
-
),
|
|
531
|
-
},
|
|
532
|
-
omit=omit,
|
|
533
|
-
)
|
|
534
|
-
)
|
|
535
|
-
)
|
|
536
|
-
),
|
|
622
|
+
params=_encoded_params if _encoded_params else None,
|
|
537
623
|
json=json_body,
|
|
538
624
|
data=data_body,
|
|
539
625
|
content=content,
|