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.
Files changed (159) hide show
  1. samsara/__init__.py +69 -9
  2. samsara/addresses/client.py +7 -14
  3. samsara/addresses/raw_client.py +7 -11
  4. samsara/alerts/client.py +2 -10
  5. samsara/alerts/raw_client.py +180 -180
  6. samsara/assets/__init__.py +3 -3
  7. samsara/assets/client.py +14 -45
  8. samsara/assets/raw_client.py +156 -160
  9. samsara/assets/types/__init__.py +3 -3
  10. samsara/assets/types/{assets_list_request_type.py → list_assets_request_type.py} +1 -1
  11. samsara/attributes/client.py +0 -4
  12. samsara/beta_ap_is/client.py +137 -187
  13. samsara/beta_ap_is/raw_client.py +2035 -1702
  14. samsara/carrier_proposed_assignments/client.py +2 -10
  15. samsara/client.py +5 -0
  16. samsara/coaching/client.py +2 -22
  17. samsara/coaching/raw_client.py +108 -108
  18. samsara/contacts/client.py +2 -8
  19. samsara/core/__init__.py +5 -0
  20. samsara/core/client_wrapper.py +18 -10
  21. samsara/core/custom_pagination.py +152 -0
  22. samsara/core/http_client.py +176 -90
  23. samsara/core/http_sse/__init__.py +42 -0
  24. samsara/core/http_sse/_api.py +112 -0
  25. samsara/core/http_sse/_decoders.py +61 -0
  26. samsara/core/http_sse/_exceptions.py +7 -0
  27. samsara/core/http_sse/_models.py +17 -0
  28. samsara/core/pagination.py +14 -14
  29. samsara/core/pydantic_utilities.py +3 -1
  30. samsara/documents/client.py +2 -12
  31. samsara/documents/raw_client.py +180 -180
  32. samsara/driver_qr_codes/raw_client.py +108 -108
  33. samsara/driver_vehicle_assignments/client.py +0 -12
  34. samsara/driver_vehicle_assignments/raw_client.py +144 -144
  35. samsara/drivers/__init__.py +3 -3
  36. samsara/drivers/client.py +12 -23
  37. samsara/drivers/raw_client.py +48 -52
  38. samsara/drivers/types/__init__.py +3 -3
  39. samsara/drivers/types/{drivers_list_request_driver_activation_status.py → list_drivers_request_driver_activation_status.py} +1 -1
  40. samsara/equipment/client.py +6 -22
  41. samsara/errors/bad_gateway_error.py +1 -1
  42. samsara/errors/gateway_timeout_error.py +1 -1
  43. samsara/errors/internal_server_error.py +1 -1
  44. samsara/errors/method_not_allowed_error.py +1 -1
  45. samsara/errors/not_found_error.py +1 -1
  46. samsara/errors/not_implemented_error.py +1 -1
  47. samsara/errors/service_unavailable_error.py +1 -1
  48. samsara/errors/too_many_requests_error.py +1 -1
  49. samsara/errors/unauthorized_error.py +1 -1
  50. samsara/forms/__init__.py +9 -3
  51. samsara/forms/client.py +17 -10
  52. samsara/forms/raw_client.py +265 -254
  53. samsara/forms/types/__init__.py +6 -2
  54. samsara/forms/types/form_submissions_post_form_submission_request_body_status.py +5 -0
  55. samsara/fuel_and_energy/client.py +0 -30
  56. samsara/fuel_and_energy/raw_client.py +180 -180
  57. samsara/gateways/client.py +2 -6
  58. samsara/gateways/raw_client.py +108 -108
  59. samsara/hours_of_service/__init__.py +6 -3
  60. samsara/hours_of_service/client.py +13 -52
  61. samsara/hours_of_service/raw_client.py +77 -76
  62. samsara/hours_of_service/types/__init__.py +4 -2
  63. samsara/hours_of_service/types/get_hos_daily_logs_request_expand.py +5 -0
  64. samsara/hubs/client.py +2 -94
  65. samsara/hubs/raw_client.py +216 -216
  66. samsara/idling/client.py +0 -18
  67. samsara/idling/raw_client.py +36 -36
  68. samsara/ifta/client.py +4 -34
  69. samsara/ifta/raw_client.py +144 -144
  70. samsara/industrial/client.py +12 -56
  71. samsara/industrial/raw_client.py +40 -48
  72. samsara/issues/client.py +0 -4
  73. samsara/issues/raw_client.py +108 -108
  74. samsara/legacy_ap_is/client.py +4 -56
  75. samsara/legacy_ap_is/raw_client.py +108 -108
  76. samsara/live_sharing_links/client.py +2 -10
  77. samsara/live_sharing_links/raw_client.py +144 -144
  78. samsara/location_and_speed/client.py +2 -20
  79. samsara/location_and_speed/raw_client.py +36 -36
  80. samsara/maintenance/__init__.py +3 -3
  81. samsara/maintenance/client.py +15 -30
  82. samsara/maintenance/raw_client.py +191 -182
  83. samsara/maintenance/types/__init__.py +6 -2
  84. samsara/maintenance/types/create_dvir_request_type.py +5 -0
  85. samsara/media/client.py +0 -4
  86. samsara/media/raw_client.py +108 -108
  87. samsara/messages/client.py +2 -8
  88. samsara/plans/client.py +0 -21
  89. samsara/plans/raw_client.py +72 -72
  90. samsara/preview_ap_is/client.py +0 -140
  91. samsara/preview_ap_is/raw_client.py +72 -430
  92. samsara/route_events/client.py +2 -12
  93. samsara/route_events/raw_client.py +36 -36
  94. samsara/routes/__init__.py +30 -0
  95. samsara/routes/client.py +7 -35
  96. samsara/routes/raw_client.py +257 -256
  97. samsara/routes/types/__init__.py +34 -0
  98. samsara/routes/types/get_routes_feed_request_expand.py +5 -0
  99. samsara/safety/client.py +2 -10
  100. samsara/safety/raw_client.py +36 -36
  101. samsara/settings/raw_client.py +180 -180
  102. samsara/speeding_intervals/raw_client.py +36 -36
  103. samsara/tachograph_eu_only/client.py +0 -6
  104. samsara/tags/client.py +2 -8
  105. samsara/trailer_assignments/client.py +2 -18
  106. samsara/trailers/client.py +2 -12
  107. samsara/trailers/raw_client.py +180 -180
  108. samsara/types/__init__.py +54 -0
  109. samsara/types/create_routes_stop_request_object_request_body.py +7 -0
  110. samsara/types/driver_assignment_object_response_body.py +2 -1
  111. samsara/types/driver_assignment_object_response_body_assignment_type.py +5 -0
  112. samsara/types/driver_external_ids.py +1 -1
  113. samsara/types/forms_approval_config_object_response_body.py +2 -1
  114. samsara/types/forms_approval_config_object_response_body_type.py +5 -0
  115. samsara/types/fuel_level_trigger_details_object_request_body.py +2 -1
  116. samsara/types/fuel_level_trigger_details_object_request_body_operation.py +5 -0
  117. samsara/types/fuel_level_trigger_details_object_response_body.py +4 -1
  118. samsara/types/fuel_level_trigger_details_object_response_body_operation.py +5 -0
  119. samsara/types/idling_event_object_v_20251023_response_body.py +4 -3
  120. samsara/types/idling_event_object_v_20251023_response_body_pto_state.py +5 -0
  121. samsara/types/patch_issue_request_body_assigned_to_request_body.py +2 -1
  122. samsara/types/patch_issue_request_body_assigned_to_request_body_type.py +5 -0
  123. samsara/types/reading_datapoint_request_body.py +5 -4
  124. samsara/types/reading_datapoint_request_body_entity_type.py +5 -0
  125. samsara/types/reading_history_response_body.py +1 -1
  126. samsara/types/reading_snapshot_response_body.py +1 -1
  127. samsara/types/resolved_by.py +2 -1
  128. samsara/types/resolved_by_type.py +5 -0
  129. samsara/types/route_feed_object_response_body.py +2 -1
  130. samsara/types/route_feed_object_response_body_type.py +5 -0
  131. samsara/types/route_settings_request_body.py +8 -0
  132. samsara/types/route_settings_request_body_sequencing_method.py +7 -0
  133. samsara/types/route_settings_response_body.py +8 -0
  134. samsara/types/route_settings_response_body_sequencing_method.py +7 -0
  135. samsara/types/route_stop_details_object_response_body.py +2 -1
  136. samsara/types/route_stop_details_object_response_body_type.py +5 -0
  137. samsara/types/routes_stop_response_object_response_body.py +7 -0
  138. samsara/types/training_learner_object_response_body.py +2 -1
  139. samsara/types/training_learner_object_response_body_type.py +5 -0
  140. samsara/types/update_routes_stop_request_object_request_body.py +7 -0
  141. samsara/types/vehicle_assignment_object_response_body.py +4 -3
  142. samsara/types/vehicle_assignment_object_response_body_assignment_type.py +5 -0
  143. samsara/types/vehicle_external_ids.py +1 -1
  144. samsara/types/work_order_money_object_request_body.py +2 -1
  145. samsara/types/work_order_money_object_request_body_currency.py +5 -0
  146. samsara/types/work_order_money_object_response_body.py +2 -1
  147. samsara/types/work_order_money_object_response_body_currency.py +5 -0
  148. samsara/users/client.py +4 -16
  149. samsara/vehicle_locations/client.py +4 -16
  150. samsara/vehicles/client.py +7 -22
  151. samsara/vehicles/raw_client.py +43 -47
  152. samsara/webhooks/client.py +2 -10
  153. samsara/webhooks/raw_client.py +180 -180
  154. samsara/work_orders/client.py +4 -18
  155. samsara/work_orders/raw_client.py +216 -216
  156. {samsara_api-4.2.0.dist-info → samsara_api-4.3.0.dist-info}/METADATA +2 -1
  157. {samsara_api-4.2.0.dist-info → samsara_api-4.3.0.dist-info}/RECORD +159 -132
  158. {samsara_api-4.2.0.dist-info → samsara_api-4.3.0.dist-info}/LICENSE +0 -0
  159. {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",
@@ -10,7 +10,7 @@ class BaseClientWrapper:
10
10
  def __init__(
11
11
  self,
12
12
  *,
13
- token: typing.Optional[typing.Union[str, typing.Callable[[], str]]] = None,
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.2.0",
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.2.0",
28
+ "X-Fern-SDK-Version": "4.3.0",
29
29
  **(self.get_custom_headers() or {}),
30
30
  }
31
- token = self._get_token()
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) -> typing.Optional[str]:
37
- if isinstance(self._token, str) or self._token is None:
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.Optional[typing.Union[str, typing.Callable[[], str]]] = None,
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.Optional[typing.Union[str, typing.Callable[[], str]]] = None,
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")
@@ -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.5
22
- MAX_RETRY_DELAY_SECONDS = 10
23
- MAX_RETRY_DELAY_SECONDS_FROM_HEADER = 30
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
- # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says.
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 <= MAX_RETRY_DELAY_SECONDS_FROM_HEADER:
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
- # Apply exponential backoff, capped at MAX_RETRY_DELAY_SECONDS.
80
- retry_delay = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS)
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
- # Add a randomness / jitter to the retry delay to avoid overwhelming the server with retries.
83
- timeout = retry_delay * (1 - 0.25 * random())
84
- return timeout if timeout >= 0 else 0
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 = 2,
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=encode_query(
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", 0) if request_options is not None else 0
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 max_retries > retries:
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 = 2,
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=encode_query(
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 = 2,
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
- **self.base_headers(),
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=encode_query(
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", 0) if request_options is not None else 0
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 max_retries > retries:
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 = 2,
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
- **self.base_headers(),
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=encode_query(
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,