statuspro-openapi-client 0.1.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.
- statuspro_openapi_client-0.1.0.dist-info/METADATA +337 -0
- statuspro_openapi_client-0.1.0.dist-info/RECORD +74 -0
- statuspro_openapi_client-0.1.0.dist-info/WHEEL +4 -0
- statuspro_openapi_client-0.1.0.dist-info/entry_points.txt +3 -0
- statuspro_openapi_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- statuspro_public_api_client/__init__.py +36 -0
- statuspro_public_api_client/_logging.py +33 -0
- statuspro_public_api_client/api/__init__.py +1 -0
- statuspro_public_api_client/api/orders/__init__.py +1 -0
- statuspro_public_api_client/api/orders/add_order_comment.py +215 -0
- statuspro_public_api_client/api/orders/bulk_update_order_status.py +194 -0
- statuspro_public_api_client/api/orders/get_order.py +188 -0
- statuspro_public_api_client/api/orders/get_viable_statuses.py +193 -0
- statuspro_public_api_client/api/orders/list_orders.py +366 -0
- statuspro_public_api_client/api/orders/lookup_order.py +208 -0
- statuspro_public_api_client/api/orders/set_order_due_date.py +215 -0
- statuspro_public_api_client/api/orders/update_order_status.py +215 -0
- statuspro_public_api_client/api/statuses/__init__.py +1 -0
- statuspro_public_api_client/api/statuses/get_statuses.py +161 -0
- statuspro_public_api_client/api_wrapper/__init__.py +15 -0
- statuspro_public_api_client/api_wrapper/_namespace.py +40 -0
- statuspro_public_api_client/api_wrapper/_registry.py +43 -0
- statuspro_public_api_client/api_wrapper/_resource.py +116 -0
- statuspro_public_api_client/client.py +267 -0
- statuspro_public_api_client/client_types.py +54 -0
- statuspro_public_api_client/domain/__init__.py +33 -0
- statuspro_public_api_client/domain/base.py +117 -0
- statuspro_public_api_client/domain/converters.py +71 -0
- statuspro_public_api_client/domain/order.py +87 -0
- statuspro_public_api_client/domain/status.py +30 -0
- statuspro_public_api_client/errors.py +16 -0
- statuspro_public_api_client/helpers/__init__.py +21 -0
- statuspro_public_api_client/helpers/base.py +26 -0
- statuspro_public_api_client/helpers/orders.py +78 -0
- statuspro_public_api_client/helpers/statuses.py +37 -0
- statuspro_public_api_client/log_setup.py +99 -0
- statuspro_public_api_client/models/__init__.py +53 -0
- statuspro_public_api_client/models/add_order_comment_request.py +68 -0
- statuspro_public_api_client/models/bulk_status_update_request.py +124 -0
- statuspro_public_api_client/models/bulk_status_update_response.py +72 -0
- statuspro_public_api_client/models/customer.py +74 -0
- statuspro_public_api_client/models/error_response.py +58 -0
- statuspro_public_api_client/models/history_item.py +171 -0
- statuspro_public_api_client/models/list_orders_financial_status_item.py +15 -0
- statuspro_public_api_client/models/list_orders_fulfillment_status_item.py +11 -0
- statuspro_public_api_client/models/locale_translation.py +66 -0
- statuspro_public_api_client/models/mail_log.py +82 -0
- statuspro_public_api_client/models/message_response.py +58 -0
- statuspro_public_api_client/models/order_list_item.py +180 -0
- statuspro_public_api_client/models/order_list_meta.py +120 -0
- statuspro_public_api_client/models/order_list_response.py +81 -0
- statuspro_public_api_client/models/order_response.py +220 -0
- statuspro_public_api_client/models/progress_timeline_item.py +93 -0
- statuspro_public_api_client/models/set_due_date_request.py +95 -0
- statuspro_public_api_client/models/status.py +190 -0
- statuspro_public_api_client/models/status_definition.py +82 -0
- statuspro_public_api_client/models/status_translations.py +62 -0
- statuspro_public_api_client/models/update_order_status_request.py +92 -0
- statuspro_public_api_client/models/validation_error_response.py +81 -0
- statuspro_public_api_client/models/validation_error_response_errors.py +54 -0
- statuspro_public_api_client/models/viable_status.py +82 -0
- statuspro_public_api_client/models_pydantic/__init__.py +122 -0
- statuspro_public_api_client/models_pydantic/_auto_registry.py +115 -0
- statuspro_public_api_client/models_pydantic/_base.py +349 -0
- statuspro_public_api_client/models_pydantic/_generated/__init__.py +53 -0
- statuspro_public_api_client/models_pydantic/_generated/errors.py +24 -0
- statuspro_public_api_client/models_pydantic/_generated/orders.py +136 -0
- statuspro_public_api_client/models_pydantic/_generated/statuses.py +25 -0
- statuspro_public_api_client/models_pydantic/_registry.py +171 -0
- statuspro_public_api_client/models_pydantic/converters.py +184 -0
- statuspro_public_api_client/py.typed +1 -0
- statuspro_public_api_client/statuspro-openapi.yaml +859 -0
- statuspro_public_api_client/statuspro_client.py +1156 -0
- statuspro_public_api_client/utils.py +290 -0
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StatusProClient - The pythonic StatusPro API client with automatic resilience.
|
|
3
|
+
|
|
4
|
+
This client uses httpx's native transport layer to provide automatic retries,
|
|
5
|
+
rate limiting, error handling, and pagination for all API calls without any
|
|
6
|
+
decorators or wrapper methods needed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import netrc
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
from http import HTTPStatus
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
18
|
+
from urllib.parse import parse_qs, quote, urlencode, urlparse, urlunparse
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .helpers.orders import Orders
|
|
22
|
+
from .helpers.statuses import Statuses
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from dotenv import load_dotenv
|
|
26
|
+
from httpx import AsyncHTTPTransport
|
|
27
|
+
from httpx_retries import Retry, RetryTransport
|
|
28
|
+
|
|
29
|
+
from ._logging import Logger
|
|
30
|
+
from .api_wrapper import ApiNamespace
|
|
31
|
+
from .client import AuthenticatedClient
|
|
32
|
+
|
|
33
|
+
# Patterns used to identify sensitive query parameters and body fields in logs.
|
|
34
|
+
# Values matching these patterns are redacted to prevent information disclosure.
|
|
35
|
+
# See also: statuspro_mcp_server/src/statuspro_mcp/logging.py filter_sensitive_data()
|
|
36
|
+
# for the MCP equivalent.
|
|
37
|
+
_SENSITIVE_PARAMS: frozenset[str] = frozenset(
|
|
38
|
+
{
|
|
39
|
+
"api_key",
|
|
40
|
+
"auth",
|
|
41
|
+
"authorization",
|
|
42
|
+
"credential",
|
|
43
|
+
"email",
|
|
44
|
+
"key",
|
|
45
|
+
"password",
|
|
46
|
+
"secret",
|
|
47
|
+
"token",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_REDACTED = "***"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_sensitive(name: str) -> bool:
|
|
55
|
+
"""Check if a parameter/field name matches any sensitive pattern."""
|
|
56
|
+
lower = name.lower()
|
|
57
|
+
return any(pattern in lower for pattern in _SENSITIVE_PARAMS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _sanitize_url(url: str) -> str:
|
|
61
|
+
"""Redact sensitive query parameter values from a URL for safe logging."""
|
|
62
|
+
try:
|
|
63
|
+
parsed = urlparse(url)
|
|
64
|
+
if not parsed.query:
|
|
65
|
+
return url
|
|
66
|
+
params = parse_qs(parsed.query, keep_blank_values=True)
|
|
67
|
+
sanitized = {}
|
|
68
|
+
for k, values in params.items():
|
|
69
|
+
if _is_sensitive(k):
|
|
70
|
+
sanitized[k] = [_REDACTED]
|
|
71
|
+
else:
|
|
72
|
+
sanitized[k] = values
|
|
73
|
+
# Use urlencode with custom quote function that preserves * characters
|
|
74
|
+
clean_query = urlencode(
|
|
75
|
+
sanitized,
|
|
76
|
+
doseq=True,
|
|
77
|
+
quote_via=lambda s, safe="", encoding=None, errors=None: quote(
|
|
78
|
+
s, safe=safe + "*", encoding=encoding, errors=errors
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
return urlunparse(parsed._replace(query=clean_query))
|
|
82
|
+
except Exception:
|
|
83
|
+
# If URL parsing fails, strip the query string entirely
|
|
84
|
+
base, _, _ = url.partition("?")
|
|
85
|
+
return f"{base}?{_REDACTED}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _sanitize_body(body: Any) -> Any:
|
|
89
|
+
"""Redact sensitive field values from nested dict/list bodies for safe logging."""
|
|
90
|
+
|
|
91
|
+
def _sanitize_value(value: Any) -> Any:
|
|
92
|
+
"""Recursively sanitize nested structures."""
|
|
93
|
+
if isinstance(value, dict):
|
|
94
|
+
return {
|
|
95
|
+
k: _REDACTED if _is_sensitive(k) else _sanitize_value(v)
|
|
96
|
+
for k, v in value.items()
|
|
97
|
+
}
|
|
98
|
+
if isinstance(value, list):
|
|
99
|
+
return [_sanitize_value(item) for item in value]
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
if not isinstance(body, dict):
|
|
103
|
+
return "[non-dict body]"
|
|
104
|
+
return _sanitize_value(body)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RateLimitAwareRetry(Retry):
|
|
108
|
+
"""
|
|
109
|
+
Custom Retry class that allows non-idempotent methods (POST, PATCH) to be
|
|
110
|
+
retried ONLY when receiving a 429 (Too Many Requests) status code.
|
|
111
|
+
|
|
112
|
+
For all other retryable status codes (502, 503, 504), only idempotent methods
|
|
113
|
+
(HEAD, GET, PUT, DELETE, OPTIONS, TRACE) will be retried.
|
|
114
|
+
|
|
115
|
+
This ensures we don't accidentally retry non-idempotent operations after
|
|
116
|
+
server errors, but we DO retry them when we're being rate-limited.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
# Idempotent methods that are always safe to retry
|
|
120
|
+
IDEMPOTENT_METHODS = frozenset(["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"])
|
|
121
|
+
|
|
122
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
123
|
+
"""Initialize and track the current request method."""
|
|
124
|
+
super().__init__(*args, **kwargs)
|
|
125
|
+
self._current_method: str | None = None
|
|
126
|
+
|
|
127
|
+
def is_retryable_method(self, method: str) -> bool:
|
|
128
|
+
"""
|
|
129
|
+
Allow all methods to pass through the initial check.
|
|
130
|
+
|
|
131
|
+
Store the method for later use in is_retryable_status_code.
|
|
132
|
+
"""
|
|
133
|
+
self._current_method = method.upper()
|
|
134
|
+
# Accept all methods - we'll filter in is_retryable_status_code
|
|
135
|
+
return self._current_method in self.allowed_methods
|
|
136
|
+
|
|
137
|
+
def is_retryable_status_code(self, status_code: int) -> bool:
|
|
138
|
+
"""
|
|
139
|
+
Check if a status code is retryable for the current method.
|
|
140
|
+
|
|
141
|
+
For 429 (rate limiting), allow all methods.
|
|
142
|
+
For other errors (502, 503, 504), only allow idempotent methods.
|
|
143
|
+
"""
|
|
144
|
+
# First check if the status code is in the allowed list at all
|
|
145
|
+
if status_code not in self.status_forcelist:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
# If we don't know the method, fall back to default behavior
|
|
149
|
+
if self._current_method is None:
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
# Rate limiting (429) - retry all methods
|
|
153
|
+
if status_code == HTTPStatus.TOO_MANY_REQUESTS:
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
# Other retryable errors - only retry idempotent methods
|
|
157
|
+
return self._current_method in self.IDEMPOTENT_METHODS
|
|
158
|
+
|
|
159
|
+
def increment(self) -> "RateLimitAwareRetry":
|
|
160
|
+
"""Return a new retry instance with the attempt count incremented."""
|
|
161
|
+
# Call parent's increment which creates a new instance of our class
|
|
162
|
+
new_retry = cast(RateLimitAwareRetry, super().increment())
|
|
163
|
+
# Preserve the current method across retry attempts
|
|
164
|
+
new_retry._current_method = self._current_method
|
|
165
|
+
return new_retry
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ErrorLoggingTransport(AsyncHTTPTransport):
|
|
169
|
+
"""
|
|
170
|
+
Transport layer that adds detailed error logging for 4xx client errors.
|
|
171
|
+
|
|
172
|
+
This transport wraps another AsyncHTTPTransport and intercepts responses
|
|
173
|
+
to log detailed error information using the generated error models.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
wrapped_transport: AsyncHTTPTransport | None = None,
|
|
179
|
+
logger: Logger | None = None,
|
|
180
|
+
**kwargs: Any,
|
|
181
|
+
):
|
|
182
|
+
"""
|
|
183
|
+
Initialize the error logging transport.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
wrapped_transport: The transport to wrap. If None, creates a new AsyncHTTPTransport.
|
|
187
|
+
logger: Logger instance for capturing error details. If None, creates a default logger.
|
|
188
|
+
**kwargs: Additional arguments passed to AsyncHTTPTransport if wrapped_transport is None.
|
|
189
|
+
"""
|
|
190
|
+
super().__init__()
|
|
191
|
+
if wrapped_transport is None:
|
|
192
|
+
wrapped_transport = AsyncHTTPTransport(**kwargs)
|
|
193
|
+
self._wrapped_transport = wrapped_transport
|
|
194
|
+
self.logger: Logger = logger or logging.getLogger(__name__)
|
|
195
|
+
|
|
196
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
197
|
+
"""Handle request and log detailed error information for 4xx responses."""
|
|
198
|
+
response = await self._wrapped_transport.handle_async_request(request)
|
|
199
|
+
|
|
200
|
+
# Log detailed information for 400-level client errors
|
|
201
|
+
if 400 <= response.status_code < 500:
|
|
202
|
+
await self._log_client_error(response, request)
|
|
203
|
+
|
|
204
|
+
return response
|
|
205
|
+
|
|
206
|
+
async def _log_client_error(
|
|
207
|
+
self, response: httpx.Response, request: httpx.Request
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Log 4xx client errors.
|
|
210
|
+
|
|
211
|
+
StatusPro returns either ``ErrorResponse`` ({"message": str}) or
|
|
212
|
+
``ValidationErrorResponse`` ({"message": str, "errors": {field: [str]}}).
|
|
213
|
+
We log the untyped JSON since both shapes are simple enough to render
|
|
214
|
+
directly without a dedicated typed model.
|
|
215
|
+
"""
|
|
216
|
+
method = request.method
|
|
217
|
+
url = _sanitize_url(str(request.url))
|
|
218
|
+
status_code = response.status_code
|
|
219
|
+
|
|
220
|
+
request_body: Any = None
|
|
221
|
+
if request.content:
|
|
222
|
+
with contextlib.suppress(
|
|
223
|
+
json.JSONDecodeError, UnicodeDecodeError, AttributeError, TypeError
|
|
224
|
+
):
|
|
225
|
+
request_body = json.loads(request.content.decode("utf-8"))
|
|
226
|
+
|
|
227
|
+
if hasattr(response, "aread"):
|
|
228
|
+
with contextlib.suppress(TypeError, AttributeError):
|
|
229
|
+
await response.aread()
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
error_data = response.json()
|
|
233
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
234
|
+
self.logger.error(
|
|
235
|
+
f"Client error {status_code} for {method} {url} - "
|
|
236
|
+
f"Response: {getattr(response, 'text', '')[:500]}..."
|
|
237
|
+
)
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
prefix = (
|
|
241
|
+
f"Validation error 422 for {method} {url}"
|
|
242
|
+
if status_code == 422
|
|
243
|
+
else f"Client error {status_code} for {method} {url}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if isinstance(error_data, dict):
|
|
247
|
+
message = error_data.get("message") or "(not provided)"
|
|
248
|
+
log_message = f"{prefix}\n Error: {message}"
|
|
249
|
+
|
|
250
|
+
errors = error_data.get("errors")
|
|
251
|
+
if isinstance(errors, dict) and errors:
|
|
252
|
+
log_message += f"\n Validation errors ({len(errors)} fields):"
|
|
253
|
+
for field, field_errors in errors.items():
|
|
254
|
+
sent_value = (
|
|
255
|
+
request_body.get(field)
|
|
256
|
+
if isinstance(request_body, dict)
|
|
257
|
+
else None
|
|
258
|
+
)
|
|
259
|
+
if sent_value is not None and _is_sensitive(str(field)):
|
|
260
|
+
sent_value = _REDACTED
|
|
261
|
+
log_message += f"\n - {field}: {field_errors}"
|
|
262
|
+
if sent_value is not None:
|
|
263
|
+
log_message += f"\n Sent: {sent_value!r}"
|
|
264
|
+
self.logger.error(log_message)
|
|
265
|
+
else:
|
|
266
|
+
self.logger.error(f"{prefix}\n Raw error: {_sanitize_body(error_data)}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class PaginationTransport(AsyncHTTPTransport):
|
|
270
|
+
"""
|
|
271
|
+
Transport layer that adds automatic pagination for GET requests.
|
|
272
|
+
|
|
273
|
+
Auto-pagination behavior (for StatusPro's ``page``/``per_page`` scheme):
|
|
274
|
+
- ON by default for GET requests with NO page parameter in URL
|
|
275
|
+
- Uses 100 items per page (StatusPro's max) when no ``per_page`` is specified
|
|
276
|
+
- If caller specifies ``per_page``, that value is respected
|
|
277
|
+
- ANY explicit ``page`` parameter disables auto-pagination
|
|
278
|
+
- Disabled when request has ``extensions={"auto_pagination": False}``
|
|
279
|
+
- Only applies to GET requests
|
|
280
|
+
- Only applies to wrapped list responses (``{"data": [...], "meta": {...}}``);
|
|
281
|
+
raw-array responses like ``/statuses`` are passed through unchanged.
|
|
282
|
+
|
|
283
|
+
Controlling pagination limits:
|
|
284
|
+
- ``max_pages`` (constructor): Maximum number of pages to fetch
|
|
285
|
+
- ``max_items`` (extension): Maximum total items to collect, e.g.,
|
|
286
|
+
``extensions={"max_items": 200}``
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
wrapped_transport: AsyncHTTPTransport | None = None,
|
|
292
|
+
max_pages: int = 100,
|
|
293
|
+
logger: Logger | None = None,
|
|
294
|
+
**kwargs: Any,
|
|
295
|
+
):
|
|
296
|
+
"""
|
|
297
|
+
Initialize the pagination transport.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
wrapped_transport: The transport to wrap. If None, creates a new AsyncHTTPTransport.
|
|
301
|
+
max_pages: Maximum number of pages to collect during auto-pagination. Defaults to 100.
|
|
302
|
+
logger: Logger instance for capturing pagination operations. If None, creates a default logger.
|
|
303
|
+
**kwargs: Additional arguments passed to AsyncHTTPTransport if wrapped_transport is None.
|
|
304
|
+
"""
|
|
305
|
+
# If no wrapped transport provided, create a base one
|
|
306
|
+
if wrapped_transport is None:
|
|
307
|
+
wrapped_transport = AsyncHTTPTransport(**kwargs)
|
|
308
|
+
super().__init__()
|
|
309
|
+
else:
|
|
310
|
+
super().__init__()
|
|
311
|
+
|
|
312
|
+
self._wrapped_transport = wrapped_transport
|
|
313
|
+
self.max_pages = max_pages
|
|
314
|
+
self.logger: Logger = logger or logging.getLogger(__name__)
|
|
315
|
+
|
|
316
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
317
|
+
"""Handle request with automatic pagination for GET requests.
|
|
318
|
+
|
|
319
|
+
Auto-pagination is ON by default for GET requests. It is disabled when:
|
|
320
|
+
- `extensions={"auto_pagination": False}` is set, OR
|
|
321
|
+
- ANY explicit `page` parameter is in the URL (e.g., `?page=1` or `?page=2`)
|
|
322
|
+
|
|
323
|
+
To get auto-pagination, simply don't pass a page parameter. The transport
|
|
324
|
+
will automatically use 100 items per page (StatusPro's max) unless you specify
|
|
325
|
+
a limit, in which case your limit will be respected.
|
|
326
|
+
"""
|
|
327
|
+
# Check if auto-pagination is explicitly disabled via request extensions
|
|
328
|
+
auto_pagination = request.extensions.get("auto_pagination", True)
|
|
329
|
+
|
|
330
|
+
# ANY page param in URL disables auto-pagination - caller wants specific page
|
|
331
|
+
has_explicit_page = "page" in request.url.params
|
|
332
|
+
|
|
333
|
+
# Only paginate GET requests when auto_pagination is enabled and no explicit page
|
|
334
|
+
should_paginate = (
|
|
335
|
+
request.method == "GET" and auto_pagination and not has_explicit_page
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if should_paginate:
|
|
339
|
+
return await self._handle_paginated_request(request)
|
|
340
|
+
else:
|
|
341
|
+
# For non-paginated requests, just pass through to wrapped transport
|
|
342
|
+
return await self._wrapped_transport.handle_async_request(request)
|
|
343
|
+
|
|
344
|
+
async def _handle_paginated_request(self, request: httpx.Request) -> httpx.Response:
|
|
345
|
+
"""
|
|
346
|
+
Handle paginated requests by automatically collecting all pages.
|
|
347
|
+
|
|
348
|
+
This method detects paginated responses and automatically collects all available
|
|
349
|
+
pages up to the configured maximum. It preserves the original request structure
|
|
350
|
+
while combining data from multiple pages.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
request: The HTTP request to handle (must be a GET request).
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
A combined HTTP response containing data from all collected pages with
|
|
357
|
+
pagination metadata in the response body.
|
|
358
|
+
|
|
359
|
+
Note:
|
|
360
|
+
- Auto-pagination is ON by default for all GET requests
|
|
361
|
+
- If response has no pagination info, returns the single response as-is
|
|
362
|
+
- The response contains an 'auto_paginated' flag in the pagination metadata
|
|
363
|
+
- Data from all pages is combined into a single 'data' array
|
|
364
|
+
- Use `extensions={"max_items": N}` to limit total items collected
|
|
365
|
+
"""
|
|
366
|
+
all_data: list[Any] = []
|
|
367
|
+
current_page = 1
|
|
368
|
+
total_pages: int | None = None
|
|
369
|
+
page_num = 1
|
|
370
|
+
response: httpx.Response | None = None
|
|
371
|
+
original_is_raw_list = False
|
|
372
|
+
|
|
373
|
+
# Get max_items limit from extensions (None = unlimited)
|
|
374
|
+
max_items: int | None = request.extensions.get("max_items")
|
|
375
|
+
|
|
376
|
+
# Parse initial parameters, preserving multi-value query params
|
|
377
|
+
# (e.g., tags[]=a&tags[]=b). Using multi_items() instead of dict()
|
|
378
|
+
# to avoid losing duplicate keys.
|
|
379
|
+
base_params = [
|
|
380
|
+
(k, v)
|
|
381
|
+
for k, v in request.url.params.multi_items()
|
|
382
|
+
if k not in ("page", "per_page")
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
# Get caller's per_page or default to 100 (StatusPro's max)
|
|
386
|
+
original_per_page = request.url.params.get("per_page")
|
|
387
|
+
try:
|
|
388
|
+
page_size = int(original_per_page) if original_per_page else 100
|
|
389
|
+
if page_size <= 0:
|
|
390
|
+
self.logger.warning(
|
|
391
|
+
"Invalid per_page parameter %r (must be positive), using 100",
|
|
392
|
+
original_per_page,
|
|
393
|
+
)
|
|
394
|
+
page_size = 100
|
|
395
|
+
elif page_size > 100:
|
|
396
|
+
self.logger.warning(
|
|
397
|
+
"per_page %d exceeds StatusPro's max of 100, clamping",
|
|
398
|
+
page_size,
|
|
399
|
+
)
|
|
400
|
+
page_size = 100
|
|
401
|
+
except (ValueError, TypeError):
|
|
402
|
+
self.logger.warning(
|
|
403
|
+
"Invalid per_page parameter %r, using 100", original_per_page
|
|
404
|
+
)
|
|
405
|
+
page_size = 100
|
|
406
|
+
|
|
407
|
+
self.logger.info("Auto-paginating request: %s", _sanitize_url(str(request.url)))
|
|
408
|
+
|
|
409
|
+
for page_num in range(1, self.max_pages + 1):
|
|
410
|
+
# Determine per_page for this request
|
|
411
|
+
if max_items is not None:
|
|
412
|
+
remaining = max_items - len(all_data)
|
|
413
|
+
if remaining <= 0:
|
|
414
|
+
break
|
|
415
|
+
current_per_page = str(min(page_size, remaining))
|
|
416
|
+
else:
|
|
417
|
+
current_per_page = str(page_size)
|
|
418
|
+
|
|
419
|
+
# Build params with updated page/per_page, preserving multi-value params
|
|
420
|
+
url_params = [
|
|
421
|
+
*base_params,
|
|
422
|
+
("page", str(page_num)),
|
|
423
|
+
("per_page", current_per_page),
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
# Create a new request with updated parameters
|
|
427
|
+
paginated_request = httpx.Request(
|
|
428
|
+
method=request.method,
|
|
429
|
+
url=request.url.copy_with(params=url_params),
|
|
430
|
+
headers=request.headers,
|
|
431
|
+
content=request.content,
|
|
432
|
+
extensions=request.extensions,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Make the request using the wrapped transport
|
|
436
|
+
response = await self._wrapped_transport.handle_async_request(
|
|
437
|
+
paginated_request
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if response.status_code != 200:
|
|
441
|
+
# If we get an error, return the original response
|
|
442
|
+
return response
|
|
443
|
+
|
|
444
|
+
# Parse the response
|
|
445
|
+
try:
|
|
446
|
+
# Read the response content if it's streaming
|
|
447
|
+
if hasattr(response, "aread"):
|
|
448
|
+
with contextlib.suppress(TypeError, AttributeError):
|
|
449
|
+
# Skip aread if it's not async (e.g., in tests with mocks)
|
|
450
|
+
await response.aread()
|
|
451
|
+
|
|
452
|
+
data = response.json()
|
|
453
|
+
|
|
454
|
+
# Track original response format on first page
|
|
455
|
+
if page_num == 1:
|
|
456
|
+
original_is_raw_list = isinstance(data, list)
|
|
457
|
+
|
|
458
|
+
# Extract pagination info from headers or response body
|
|
459
|
+
pagination_info = self._extract_pagination_info(response, data)
|
|
460
|
+
|
|
461
|
+
if pagination_info:
|
|
462
|
+
current_page = pagination_info.get("page", page_num)
|
|
463
|
+
total_pages = pagination_info.get("total_pages")
|
|
464
|
+
|
|
465
|
+
# Extract the actual data items
|
|
466
|
+
if isinstance(data, list):
|
|
467
|
+
items = data
|
|
468
|
+
else:
|
|
469
|
+
items = data.get("data", [])
|
|
470
|
+
all_data.extend(items)
|
|
471
|
+
|
|
472
|
+
# Check max_items limit
|
|
473
|
+
if max_items is not None and len(all_data) >= max_items:
|
|
474
|
+
all_data = all_data[:max_items] # Truncate to exact limit
|
|
475
|
+
self.logger.info(
|
|
476
|
+
"Reached max_items limit (%d), stopping pagination",
|
|
477
|
+
max_items,
|
|
478
|
+
)
|
|
479
|
+
break
|
|
480
|
+
|
|
481
|
+
# Check if we're done
|
|
482
|
+
# Break if we've reached the last known page or got an empty page
|
|
483
|
+
if (total_pages and current_page >= total_pages) or len(items) == 0:
|
|
484
|
+
break
|
|
485
|
+
|
|
486
|
+
self.logger.debug(
|
|
487
|
+
"Collected page %s/%s, items: %d, total so far: %d",
|
|
488
|
+
current_page,
|
|
489
|
+
total_pages or "?",
|
|
490
|
+
len(items),
|
|
491
|
+
len(all_data),
|
|
492
|
+
)
|
|
493
|
+
else:
|
|
494
|
+
# No pagination info - return response preserving its shape
|
|
495
|
+
self.logger.info(
|
|
496
|
+
"No pagination info found, returning single-page response"
|
|
497
|
+
)
|
|
498
|
+
# Apply max_items truncation if set
|
|
499
|
+
if max_items is not None:
|
|
500
|
+
if isinstance(data, list) and len(data) > max_items:
|
|
501
|
+
truncated = json.dumps(data[:max_items]).encode()
|
|
502
|
+
headers = dict(response.headers)
|
|
503
|
+
headers.pop("content-encoding", None)
|
|
504
|
+
headers.pop("content-length", None)
|
|
505
|
+
return httpx.Response(
|
|
506
|
+
status_code=200,
|
|
507
|
+
headers=headers,
|
|
508
|
+
content=truncated,
|
|
509
|
+
request=request,
|
|
510
|
+
)
|
|
511
|
+
if isinstance(data, dict) and "data" in data:
|
|
512
|
+
items = data["data"]
|
|
513
|
+
if isinstance(items, list) and len(items) > max_items:
|
|
514
|
+
data["data"] = items[:max_items]
|
|
515
|
+
truncated = json.dumps(data).encode()
|
|
516
|
+
headers = dict(response.headers)
|
|
517
|
+
headers.pop("content-encoding", None)
|
|
518
|
+
headers.pop("content-length", None)
|
|
519
|
+
return httpx.Response(
|
|
520
|
+
status_code=200,
|
|
521
|
+
headers=headers,
|
|
522
|
+
content=truncated,
|
|
523
|
+
request=request,
|
|
524
|
+
)
|
|
525
|
+
return response
|
|
526
|
+
|
|
527
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
528
|
+
self.logger.warning("Failed to parse paginated response: %s", e)
|
|
529
|
+
return response
|
|
530
|
+
|
|
531
|
+
# Ensure we have a response at this point
|
|
532
|
+
if response is None:
|
|
533
|
+
msg = "No response available after pagination"
|
|
534
|
+
raise RuntimeError(msg)
|
|
535
|
+
|
|
536
|
+
# Create a combined response, preserving the original response shape
|
|
537
|
+
if original_is_raw_list:
|
|
538
|
+
# Original endpoint returned a raw JSON list - preserve that format
|
|
539
|
+
combined_content = json.dumps(all_data).encode()
|
|
540
|
+
else:
|
|
541
|
+
combined_data: dict[str, Any] = {"data": all_data}
|
|
542
|
+
# Add pagination metadata
|
|
543
|
+
if total_pages:
|
|
544
|
+
combined_data["pagination"] = {
|
|
545
|
+
"total_pages": total_pages,
|
|
546
|
+
"collected_pages": page_num,
|
|
547
|
+
"total_items": len(all_data),
|
|
548
|
+
"auto_paginated": True,
|
|
549
|
+
}
|
|
550
|
+
combined_content = json.dumps(combined_data).encode()
|
|
551
|
+
|
|
552
|
+
# Remove content-encoding headers to avoid compression issues
|
|
553
|
+
headers = dict(response.headers)
|
|
554
|
+
headers.pop("content-encoding", None)
|
|
555
|
+
headers.pop("content-length", None) # Will be recalculated
|
|
556
|
+
|
|
557
|
+
combined_response = httpx.Response(
|
|
558
|
+
status_code=200,
|
|
559
|
+
headers=headers,
|
|
560
|
+
content=combined_content,
|
|
561
|
+
request=request,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
self.logger.info(
|
|
565
|
+
"Auto-pagination complete: collected %d items from %d pages",
|
|
566
|
+
len(all_data),
|
|
567
|
+
page_num,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
return combined_response
|
|
571
|
+
|
|
572
|
+
def _normalize_pagination_values(
|
|
573
|
+
self, pagination_info: dict[str, Any]
|
|
574
|
+
) -> dict[str, Any]:
|
|
575
|
+
"""Convert pagination values from strings to appropriate Python types.
|
|
576
|
+
|
|
577
|
+
JSON parsing may return numeric values as strings (e.g., "41" instead of 41).
|
|
578
|
+
String comparison produces incorrect results: "5" >= "41" is True because
|
|
579
|
+
"5" > "4" lexicographically. This method ensures all numeric pagination
|
|
580
|
+
fields are proper integers for correct comparisons.
|
|
581
|
+
|
|
582
|
+
Additionally, boolean fields like first_page and last_page may come as
|
|
583
|
+
string values ("true"/"false") and are converted to Python booleans.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
pagination_info: Dictionary containing pagination metadata.
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
Dictionary with numeric fields converted to integers and boolean
|
|
590
|
+
fields converted to booleans.
|
|
591
|
+
"""
|
|
592
|
+
# Fields that should be integers for pagination comparisons
|
|
593
|
+
numeric_fields = [
|
|
594
|
+
"page",
|
|
595
|
+
"total_pages",
|
|
596
|
+
"total_items",
|
|
597
|
+
"limit",
|
|
598
|
+
"offset",
|
|
599
|
+
"count",
|
|
600
|
+
"per_page",
|
|
601
|
+
"current_page",
|
|
602
|
+
"total_records",
|
|
603
|
+
]
|
|
604
|
+
|
|
605
|
+
# Fields that should be booleans (API returns "true"/"false" strings)
|
|
606
|
+
boolean_fields = [
|
|
607
|
+
"first_page",
|
|
608
|
+
"last_page",
|
|
609
|
+
]
|
|
610
|
+
|
|
611
|
+
result = pagination_info.copy()
|
|
612
|
+
|
|
613
|
+
# Convert numeric fields
|
|
614
|
+
for field in numeric_fields:
|
|
615
|
+
if field in result:
|
|
616
|
+
value = result[field]
|
|
617
|
+
# Convert string numbers to integers
|
|
618
|
+
if isinstance(value, str):
|
|
619
|
+
try:
|
|
620
|
+
result[field] = int(value)
|
|
621
|
+
except ValueError:
|
|
622
|
+
self.logger.warning(
|
|
623
|
+
"Invalid pagination value for %s: %r, removing field",
|
|
624
|
+
field,
|
|
625
|
+
value,
|
|
626
|
+
)
|
|
627
|
+
# Remove invalid field so fallback values are used
|
|
628
|
+
del result[field]
|
|
629
|
+
# Already an int or float - ensure it's int
|
|
630
|
+
elif isinstance(value, float):
|
|
631
|
+
# Warn if float has a fractional part (unexpected for pagination)
|
|
632
|
+
if value != int(value):
|
|
633
|
+
self.logger.warning(
|
|
634
|
+
"Pagination value %s has fractional part: %r, truncating to %d",
|
|
635
|
+
field,
|
|
636
|
+
value,
|
|
637
|
+
int(value),
|
|
638
|
+
)
|
|
639
|
+
result[field] = int(value)
|
|
640
|
+
# If it's already an int, leave it as is
|
|
641
|
+
|
|
642
|
+
# Convert boolean fields ("true"/"false" strings to Python booleans)
|
|
643
|
+
for field in boolean_fields:
|
|
644
|
+
if field in result:
|
|
645
|
+
value = result[field]
|
|
646
|
+
if isinstance(value, str):
|
|
647
|
+
lower_value = value.lower()
|
|
648
|
+
if lower_value == "true":
|
|
649
|
+
result[field] = True
|
|
650
|
+
elif lower_value == "false":
|
|
651
|
+
result[field] = False
|
|
652
|
+
else:
|
|
653
|
+
self.logger.warning(
|
|
654
|
+
"Invalid boolean pagination value for %s: %r, removing field",
|
|
655
|
+
field,
|
|
656
|
+
value,
|
|
657
|
+
)
|
|
658
|
+
del result[field]
|
|
659
|
+
elif not isinstance(value, bool):
|
|
660
|
+
# Unexpected type - convert truthy/falsy to bool
|
|
661
|
+
result[field] = bool(value)
|
|
662
|
+
|
|
663
|
+
return result
|
|
664
|
+
|
|
665
|
+
def _extract_pagination_info(
|
|
666
|
+
self, response: httpx.Response, data: dict[str, Any]
|
|
667
|
+
) -> dict[str, Any] | None:
|
|
668
|
+
"""Extract pagination information from response headers or body.
|
|
669
|
+
|
|
670
|
+
Note:
|
|
671
|
+
All numeric pagination values (page, total_pages, total_items, etc.)
|
|
672
|
+
are converted to integers to ensure correct comparisons. This is important
|
|
673
|
+
because JSON parsing may return string values, and string comparison
|
|
674
|
+
(e.g., "5" >= "41") produces incorrect results.
|
|
675
|
+
"""
|
|
676
|
+
pagination_info: dict[str, Any] = {}
|
|
677
|
+
|
|
678
|
+
# Check for X-Pagination header (JSON format)
|
|
679
|
+
if "X-Pagination" in response.headers:
|
|
680
|
+
try:
|
|
681
|
+
header_data = json.loads(response.headers["X-Pagination"])
|
|
682
|
+
# Validate that parsed JSON is a dictionary
|
|
683
|
+
if not isinstance(header_data, dict):
|
|
684
|
+
self.logger.warning(
|
|
685
|
+
"X-Pagination header is not a JSON object: %r", header_data
|
|
686
|
+
)
|
|
687
|
+
else:
|
|
688
|
+
# Convert numeric string values to integers to avoid string comparison bugs
|
|
689
|
+
# (e.g., "5" >= "41" is True in string comparison but should be False)
|
|
690
|
+
pagination_info = self._normalize_pagination_values(header_data)
|
|
691
|
+
# Only return early if we got valid pagination data
|
|
692
|
+
if pagination_info:
|
|
693
|
+
return pagination_info
|
|
694
|
+
except json.JSONDecodeError:
|
|
695
|
+
pass
|
|
696
|
+
|
|
697
|
+
# Check for individual headers (with validation for malformed values)
|
|
698
|
+
if "X-Total-Pages" in response.headers:
|
|
699
|
+
try:
|
|
700
|
+
pagination_info["total_pages"] = int(response.headers["X-Total-Pages"])
|
|
701
|
+
except ValueError:
|
|
702
|
+
self.logger.warning(
|
|
703
|
+
"Invalid X-Total-Pages header value: %s",
|
|
704
|
+
response.headers["X-Total-Pages"],
|
|
705
|
+
)
|
|
706
|
+
if "X-Current-Page" in response.headers:
|
|
707
|
+
try:
|
|
708
|
+
pagination_info["page"] = int(response.headers["X-Current-Page"])
|
|
709
|
+
except ValueError:
|
|
710
|
+
self.logger.warning(
|
|
711
|
+
"Invalid X-Current-Page header value: %s",
|
|
712
|
+
response.headers["X-Current-Page"],
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
# Check for pagination in response body
|
|
716
|
+
if isinstance(data, dict):
|
|
717
|
+
if "pagination" in data and isinstance(data["pagination"], dict):
|
|
718
|
+
pagination_info.update(
|
|
719
|
+
{str(k): v for k, v in data["pagination"].items()}
|
|
720
|
+
)
|
|
721
|
+
elif "meta" in data and isinstance(data["meta"], dict):
|
|
722
|
+
meta = data["meta"]
|
|
723
|
+
# StatusPro shape: meta has current_page, last_page, per_page, total
|
|
724
|
+
if "current_page" in meta or "last_page" in meta:
|
|
725
|
+
pagination_info.update({str(k): v for k, v in meta.items()})
|
|
726
|
+
# Map StatusPro field names to the internal canonical names so
|
|
727
|
+
# the downstream stop-condition check in _handle_paginated_request
|
|
728
|
+
# continues to use `page` / `total_pages`.
|
|
729
|
+
if "current_page" in meta:
|
|
730
|
+
pagination_info["page"] = meta["current_page"]
|
|
731
|
+
if "last_page" in meta:
|
|
732
|
+
pagination_info["total_pages"] = meta["last_page"]
|
|
733
|
+
# Alternate nested pagination shape: meta.pagination
|
|
734
|
+
elif "pagination" in meta and isinstance(meta["pagination"], dict):
|
|
735
|
+
pagination_info.update(
|
|
736
|
+
{str(k): v for k, v in meta["pagination"].items()}
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
# Normalize all numeric values to ensure correct comparisons
|
|
740
|
+
if pagination_info:
|
|
741
|
+
pagination_info = self._normalize_pagination_values(pagination_info)
|
|
742
|
+
|
|
743
|
+
return pagination_info if pagination_info else None
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def ResilientAsyncTransport(
|
|
747
|
+
max_retries: int = 5,
|
|
748
|
+
max_pages: int = 100,
|
|
749
|
+
logger: Logger | None = None,
|
|
750
|
+
**kwargs: Any,
|
|
751
|
+
) -> RetryTransport:
|
|
752
|
+
"""
|
|
753
|
+
Factory function that creates a chained transport with error logging,
|
|
754
|
+
pagination, and retry capabilities.
|
|
755
|
+
|
|
756
|
+
This function chains multiple transport layers:
|
|
757
|
+
1. AsyncHTTPTransport (base HTTP transport)
|
|
758
|
+
2. ErrorLoggingTransport (logs detailed 4xx errors)
|
|
759
|
+
3. PaginationTransport (auto-collects paginated responses)
|
|
760
|
+
4. RetryTransport (handles retries with Retry-After header support)
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
max_retries: Maximum number of retry attempts for failed requests. Defaults to 5.
|
|
764
|
+
max_pages: Maximum number of pages to collect during auto-pagination. Defaults to 100.
|
|
765
|
+
logger: Logger instance for capturing operations. If None, creates a default logger.
|
|
766
|
+
**kwargs: Additional arguments passed to the base AsyncHTTPTransport.
|
|
767
|
+
Common parameters include:
|
|
768
|
+
- http2 (bool): Enable HTTP/2 support
|
|
769
|
+
- limits (httpx.Limits): Connection pool limits
|
|
770
|
+
- verify (bool | str | ssl.SSLContext): SSL certificate verification
|
|
771
|
+
- cert (str | tuple): Client-side certificates
|
|
772
|
+
- trust_env (bool): Trust environment variables for proxy configuration
|
|
773
|
+
|
|
774
|
+
Returns:
|
|
775
|
+
A RetryTransport instance wrapping all the layered transports.
|
|
776
|
+
|
|
777
|
+
Note:
|
|
778
|
+
When using a custom transport, parameters like http2, limits, and verify
|
|
779
|
+
must be passed to this factory function (which passes them to the base
|
|
780
|
+
AsyncHTTPTransport), not to the httpx.Client/AsyncClient constructor.
|
|
781
|
+
|
|
782
|
+
Example:
|
|
783
|
+
```python
|
|
784
|
+
transport = ResilientAsyncTransport(max_retries=3, max_pages=50)
|
|
785
|
+
async with httpx.AsyncClient(transport=transport) as client:
|
|
786
|
+
response = await client.get("https://api.example.com/items")
|
|
787
|
+
```
|
|
788
|
+
"""
|
|
789
|
+
resolved_logger: Logger = (
|
|
790
|
+
logger if logger is not None else logging.getLogger(__name__)
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
# Build the transport chain from inside out:
|
|
794
|
+
# 1. Base AsyncHTTPTransport
|
|
795
|
+
base_transport = AsyncHTTPTransport(**kwargs)
|
|
796
|
+
|
|
797
|
+
# 2. Wrap with error logging
|
|
798
|
+
error_logging_transport = ErrorLoggingTransport(
|
|
799
|
+
wrapped_transport=base_transport,
|
|
800
|
+
logger=resolved_logger,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
# 3. Wrap with pagination
|
|
804
|
+
pagination_transport = PaginationTransport(
|
|
805
|
+
wrapped_transport=error_logging_transport,
|
|
806
|
+
max_pages=max_pages,
|
|
807
|
+
logger=resolved_logger,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# Finally wrap with retry logic (outermost layer)
|
|
811
|
+
# Use RateLimitAwareRetry which:
|
|
812
|
+
# - Retries ALL methods (including POST/PATCH) for 429 rate limiting
|
|
813
|
+
# - Retries ONLY idempotent methods for server errors (502, 503, 504)
|
|
814
|
+
retry = RateLimitAwareRetry(
|
|
815
|
+
total=max_retries,
|
|
816
|
+
backoff_factor=1.0, # Exponential backoff: 1, 2, 4, 8, 16 seconds
|
|
817
|
+
respect_retry_after_header=True, # Honor server's Retry-After header
|
|
818
|
+
status_forcelist=[
|
|
819
|
+
429,
|
|
820
|
+
502,
|
|
821
|
+
503,
|
|
822
|
+
504,
|
|
823
|
+
], # Status codes that should trigger retries
|
|
824
|
+
allowed_methods=[
|
|
825
|
+
"HEAD",
|
|
826
|
+
"GET",
|
|
827
|
+
"PUT",
|
|
828
|
+
"DELETE",
|
|
829
|
+
"OPTIONS",
|
|
830
|
+
"TRACE",
|
|
831
|
+
"POST",
|
|
832
|
+
"PATCH",
|
|
833
|
+
],
|
|
834
|
+
)
|
|
835
|
+
retry_transport = RetryTransport(
|
|
836
|
+
transport=pagination_transport,
|
|
837
|
+
retry=retry,
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
return retry_transport
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
class StatusProClient(AuthenticatedClient):
|
|
844
|
+
"""The pythonic StatusPro API client with automatic resilience and pagination.
|
|
845
|
+
|
|
846
|
+
Inherits from ``AuthenticatedClient`` and can be passed directly to
|
|
847
|
+
generated API methods without a ``.client`` property.
|
|
848
|
+
|
|
849
|
+
Features:
|
|
850
|
+
- Automatic retries on network errors and server errors (5xx)
|
|
851
|
+
- Automatic rate-limit handling (parses ``Retry-After``, falls back to
|
|
852
|
+
exponential backoff on 429 since StatusPro doesn't emit the header)
|
|
853
|
+
- Auto-pagination for wrapped list endpoints (``{"data": [...], "meta": {...}}``)
|
|
854
|
+
- Uses 100 items per page (StatusPro's max) by default
|
|
855
|
+
- Raw-array endpoints (``/statuses``, ``/orders/{id}/viable-statuses``) are passed through
|
|
856
|
+
- Rich logging and observability
|
|
857
|
+
|
|
858
|
+
Auto-pagination behavior:
|
|
859
|
+
- ON by default for GET requests with no ``page`` parameter
|
|
860
|
+
- ``per_page`` defaults to 100; caller values are respected (capped at 100)
|
|
861
|
+
- ANY explicit ``page`` param disables auto-pagination
|
|
862
|
+
- Disabled per-request via ``extensions={"auto_pagination": False}``
|
|
863
|
+
- ``max_pages`` constructor argument caps total pages collected
|
|
864
|
+
- ``extensions={"max_items": N}`` caps total items collected
|
|
865
|
+
|
|
866
|
+
Usage:
|
|
867
|
+
async with StatusProClient() as client:
|
|
868
|
+
from statuspro_public_api_client.api.orders import list_orders
|
|
869
|
+
|
|
870
|
+
response = await list_orders.asyncio_detailed(client=client)
|
|
871
|
+
|
|
872
|
+
# One specific page (disables auto-pagination)
|
|
873
|
+
response = await list_orders.asyncio_detailed(
|
|
874
|
+
client=client, page=2, per_page=25
|
|
875
|
+
)
|
|
876
|
+
"""
|
|
877
|
+
|
|
878
|
+
@staticmethod
|
|
879
|
+
def _read_from_netrc(base_url: str) -> str | None:
|
|
880
|
+
"""
|
|
881
|
+
Read API key from ~/.netrc file.
|
|
882
|
+
|
|
883
|
+
Args:
|
|
884
|
+
base_url: The base URL to extract the hostname from.
|
|
885
|
+
|
|
886
|
+
Returns:
|
|
887
|
+
The API key (password field) from netrc, or None if not found.
|
|
888
|
+
|
|
889
|
+
Note:
|
|
890
|
+
The password field in netrc is used to store the API token since
|
|
891
|
+
StatusPro API uses bearer token authentication, not HTTP Basic Auth.
|
|
892
|
+
"""
|
|
893
|
+
try:
|
|
894
|
+
# Extract hostname from base_url - handle both full URLs and bare hostnames
|
|
895
|
+
parsed = urlparse(base_url)
|
|
896
|
+
host: str | None = None
|
|
897
|
+
|
|
898
|
+
if parsed.hostname:
|
|
899
|
+
# URL with scheme (e.g., "https://app.orderstatuspro.com/api/v1")
|
|
900
|
+
host = parsed.hostname
|
|
901
|
+
else:
|
|
902
|
+
# Try parsing as URL without scheme (e.g., "app.orderstatuspro.com/api/v1")
|
|
903
|
+
parsed_with_scheme = urlparse(f"https://{base_url}")
|
|
904
|
+
if parsed_with_scheme.hostname:
|
|
905
|
+
host = parsed_with_scheme.hostname
|
|
906
|
+
else:
|
|
907
|
+
# Final fallback: treat as bare hostname (e.g., "api.example.com")
|
|
908
|
+
# Extract just the hostname part before any path
|
|
909
|
+
host = base_url.split("/")[0] if base_url else None
|
|
910
|
+
|
|
911
|
+
# If we couldn't extract a valid hostname, return None
|
|
912
|
+
if not host:
|
|
913
|
+
return None
|
|
914
|
+
|
|
915
|
+
netrc_path = Path.home() / ".netrc"
|
|
916
|
+
if not netrc_path.exists():
|
|
917
|
+
return None
|
|
918
|
+
|
|
919
|
+
# Warn if .netrc is readable by group or others (POSIX only)
|
|
920
|
+
if os.name != "nt":
|
|
921
|
+
mode = netrc_path.stat().st_mode
|
|
922
|
+
if mode & 0o077:
|
|
923
|
+
import warnings
|
|
924
|
+
|
|
925
|
+
warnings.warn(
|
|
926
|
+
f"~/.netrc has insecure permissions ({oct(mode & 0o777)}). "
|
|
927
|
+
"This may expose your API key. Run: chmod 600 ~/.netrc",
|
|
928
|
+
stacklevel=2,
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
auth = netrc.netrc(str(netrc_path))
|
|
932
|
+
authenticators = auth.authenticators(host)
|
|
933
|
+
|
|
934
|
+
if authenticators:
|
|
935
|
+
# Return password field (which contains our API token)
|
|
936
|
+
# netrc returns (login, account, password)
|
|
937
|
+
_login, _account, password = authenticators
|
|
938
|
+
return password
|
|
939
|
+
except (FileNotFoundError, netrc.NetrcParseError, OSError):
|
|
940
|
+
# Silently ignore netrc errors - it's an optional source
|
|
941
|
+
pass
|
|
942
|
+
|
|
943
|
+
return None
|
|
944
|
+
|
|
945
|
+
def __init__(
|
|
946
|
+
self,
|
|
947
|
+
api_key: str | None = None,
|
|
948
|
+
base_url: str | None = None,
|
|
949
|
+
timeout: float = 30.0,
|
|
950
|
+
max_retries: int = 5,
|
|
951
|
+
max_pages: int = 100,
|
|
952
|
+
logger: Logger | None = None,
|
|
953
|
+
**httpx_kwargs: Any,
|
|
954
|
+
):
|
|
955
|
+
"""
|
|
956
|
+
Initialize the StatusPro API client with automatic resilience features.
|
|
957
|
+
|
|
958
|
+
Args:
|
|
959
|
+
api_key: StatusPro API key. If None, will try to load from STATUSPRO_API_KEY env var,
|
|
960
|
+
.env file, or ~/.netrc file (in that order).
|
|
961
|
+
base_url: Base URL for the StatusPro API. Defaults to https://app.orderstatuspro.com/api/v1
|
|
962
|
+
timeout: Request timeout in seconds. Defaults to 30.0.
|
|
963
|
+
max_retries: Maximum number of retry attempts for failed requests. Defaults to 5.
|
|
964
|
+
max_pages: Maximum number of pages to collect during auto-pagination. Defaults to 100.
|
|
965
|
+
logger: Any object whose debug/info/warning/error methods accept
|
|
966
|
+
(msg, *args, **kwargs) — the standard logging.Logger call convention
|
|
967
|
+
(e.g. logging.Logger, structlog.BoundLogger). If None, creates a
|
|
968
|
+
default stdlib logger.
|
|
969
|
+
**httpx_kwargs: Additional arguments passed to the base AsyncHTTPTransport.
|
|
970
|
+
Common parameters include:
|
|
971
|
+
- http2 (bool): Enable HTTP/2 support
|
|
972
|
+
- limits (httpx.Limits): Connection pool limits
|
|
973
|
+
- verify (bool | str | ssl.SSLContext): SSL certificate verification
|
|
974
|
+
- cert (str | tuple): Client-side certificates
|
|
975
|
+
- trust_env (bool): Trust environment variables for proxy configuration
|
|
976
|
+
- event_hooks (dict): Custom event hooks (will be merged with built-in hooks)
|
|
977
|
+
|
|
978
|
+
Raises:
|
|
979
|
+
ValueError: If no API key is provided via api_key param, STATUSPRO_API_KEY env var,
|
|
980
|
+
.env file, or ~/.netrc file.
|
|
981
|
+
|
|
982
|
+
Note:
|
|
983
|
+
Transport-related parameters (http2, limits, verify, etc.) are correctly
|
|
984
|
+
passed to the innermost AsyncHTTPTransport layer, ensuring they take effect
|
|
985
|
+
even with the layered transport architecture.
|
|
986
|
+
|
|
987
|
+
Example:
|
|
988
|
+
>>> async with StatusProClient() as client:
|
|
989
|
+
... # All API calls through client get automatic resilience
|
|
990
|
+
... response = await some_api_method.asyncio_detailed(client=client)
|
|
991
|
+
"""
|
|
992
|
+
load_dotenv()
|
|
993
|
+
|
|
994
|
+
# Handle backwards compatibility: accept 'token' kwarg as alias for 'api_key'
|
|
995
|
+
if "token" in httpx_kwargs:
|
|
996
|
+
if api_key is not None:
|
|
997
|
+
raise ValueError("Cannot specify both 'api_key' and 'token' parameters")
|
|
998
|
+
api_key = httpx_kwargs.pop("token")
|
|
999
|
+
|
|
1000
|
+
# Determine base_url early so we can use it for netrc lookup
|
|
1001
|
+
base_url = (
|
|
1002
|
+
base_url
|
|
1003
|
+
or os.getenv("STATUSPRO_BASE_URL")
|
|
1004
|
+
or "https://app.orderstatuspro.com/api/v1"
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
# Setup credentials with priority: param > env (including .env) > netrc
|
|
1008
|
+
api_key = (
|
|
1009
|
+
api_key or os.getenv("STATUSPRO_API_KEY") or self._read_from_netrc(base_url)
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
if not api_key:
|
|
1013
|
+
raise ValueError(
|
|
1014
|
+
"API key required via: api_key param, STATUSPRO_API_KEY env var, "
|
|
1015
|
+
".env file, or ~/.netrc"
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
self.logger: Logger = logger or logging.getLogger(__name__)
|
|
1019
|
+
self.max_pages = max_pages
|
|
1020
|
+
|
|
1021
|
+
# Warn if SSL verification is disabled — risk of MITM attacks
|
|
1022
|
+
if httpx_kwargs.get("verify") is False:
|
|
1023
|
+
self.logger.warning(
|
|
1024
|
+
"SSL certificate verification is disabled (verify=False). "
|
|
1025
|
+
"This exposes the connection to MITM attacks. "
|
|
1026
|
+
"Only use this for local development."
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
# Domain helper instances (lazy-loaded via properties)
|
|
1030
|
+
self._orders: Orders | None = None
|
|
1031
|
+
self._statuses: Statuses | None = None
|
|
1032
|
+
self._api_namespace: ApiNamespace | None = None
|
|
1033
|
+
|
|
1034
|
+
# Extract client-level parameters that shouldn't go to the transport
|
|
1035
|
+
# Event hooks for observability - start with our defaults
|
|
1036
|
+
event_hooks: dict[str, list[Callable[[httpx.Response], Awaitable[None]]]] = {
|
|
1037
|
+
"response": [
|
|
1038
|
+
self._capture_pagination_metadata,
|
|
1039
|
+
self._log_response_metrics,
|
|
1040
|
+
]
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
# Extract and merge user hooks
|
|
1044
|
+
user_hooks = httpx_kwargs.pop("event_hooks", {})
|
|
1045
|
+
for event, hooks in user_hooks.items():
|
|
1046
|
+
# Normalize to list and add to existing or create new event
|
|
1047
|
+
hook_list = cast(
|
|
1048
|
+
list[Callable[[httpx.Response], Awaitable[None]]],
|
|
1049
|
+
hooks if isinstance(hooks, list) else [hooks],
|
|
1050
|
+
)
|
|
1051
|
+
if event in event_hooks:
|
|
1052
|
+
event_hooks[event].extend(hook_list)
|
|
1053
|
+
else:
|
|
1054
|
+
event_hooks[event] = hook_list
|
|
1055
|
+
|
|
1056
|
+
# Check if user wants to override the transport entirely
|
|
1057
|
+
custom_transport = httpx_kwargs.pop("transport", None) or httpx_kwargs.pop(
|
|
1058
|
+
"async_transport", None
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
if custom_transport:
|
|
1062
|
+
# User provided a custom transport, use it as-is
|
|
1063
|
+
transport = custom_transport
|
|
1064
|
+
else:
|
|
1065
|
+
# Separate transport-specific kwargs from client-specific kwargs
|
|
1066
|
+
# Client-specific params that should NOT go to the transport
|
|
1067
|
+
client_only_params = ["headers", "cookies", "params", "auth"]
|
|
1068
|
+
client_kwargs = {
|
|
1069
|
+
k: httpx_kwargs.pop(k)
|
|
1070
|
+
for k in list(httpx_kwargs.keys())
|
|
1071
|
+
if k in client_only_params
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
# Create resilient transport with remaining transport-specific httpx_kwargs
|
|
1075
|
+
# These will be passed to the base AsyncHTTPTransport (http2, limits, verify, etc.)
|
|
1076
|
+
transport = ResilientAsyncTransport(
|
|
1077
|
+
max_retries=max_retries,
|
|
1078
|
+
max_pages=max_pages,
|
|
1079
|
+
logger=self.logger,
|
|
1080
|
+
**httpx_kwargs, # Pass through http2, limits, verify, cert, trust_env, etc.
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
# Put client-specific params back into httpx_kwargs for the parent class
|
|
1084
|
+
httpx_kwargs.update(client_kwargs)
|
|
1085
|
+
|
|
1086
|
+
# Initialize the parent AuthenticatedClient
|
|
1087
|
+
super().__init__(
|
|
1088
|
+
base_url=base_url,
|
|
1089
|
+
token=api_key,
|
|
1090
|
+
timeout=httpx.Timeout(timeout),
|
|
1091
|
+
httpx_args={
|
|
1092
|
+
"transport": transport,
|
|
1093
|
+
"event_hooks": event_hooks,
|
|
1094
|
+
**httpx_kwargs, # Include any remaining client-level kwargs
|
|
1095
|
+
},
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
# Remove the client property since we inherit from AuthenticatedClient
|
|
1099
|
+
# Users can now pass the StatusProClient instance directly to API methods
|
|
1100
|
+
|
|
1101
|
+
# Domain properties for ergonomic access
|
|
1102
|
+
@property
|
|
1103
|
+
def orders(self) -> "Orders":
|
|
1104
|
+
"""Access order operations (list, lookup, get, update status, etc.)."""
|
|
1105
|
+
from .helpers.orders import Orders
|
|
1106
|
+
|
|
1107
|
+
if self._orders is None:
|
|
1108
|
+
self._orders = Orders(self)
|
|
1109
|
+
return self._orders
|
|
1110
|
+
|
|
1111
|
+
@property
|
|
1112
|
+
def statuses(self) -> "Statuses":
|
|
1113
|
+
"""Access status catalog operations."""
|
|
1114
|
+
from .helpers.statuses import Statuses
|
|
1115
|
+
|
|
1116
|
+
if self._statuses is None:
|
|
1117
|
+
self._statuses = Statuses(self)
|
|
1118
|
+
return self._statuses
|
|
1119
|
+
|
|
1120
|
+
@property
|
|
1121
|
+
def api(self) -> ApiNamespace:
|
|
1122
|
+
"""Thin CRUD wrappers for all API resources. Returns raw attrs models."""
|
|
1123
|
+
if self._api_namespace is None:
|
|
1124
|
+
self._api_namespace = ApiNamespace(self)
|
|
1125
|
+
return self._api_namespace
|
|
1126
|
+
|
|
1127
|
+
# Event hooks for observability
|
|
1128
|
+
async def _capture_pagination_metadata(self, response: httpx.Response) -> None:
|
|
1129
|
+
"""Capture and store pagination metadata from response headers."""
|
|
1130
|
+
if response.status_code == 200:
|
|
1131
|
+
x_pagination = response.headers.get("X-Pagination")
|
|
1132
|
+
if x_pagination:
|
|
1133
|
+
try:
|
|
1134
|
+
pagination_info = json.loads(x_pagination)
|
|
1135
|
+
self.logger.debug(f"Pagination metadata: {pagination_info}")
|
|
1136
|
+
# Store pagination info for easy access
|
|
1137
|
+
setattr(response, "pagination_info", pagination_info) # noqa: B010
|
|
1138
|
+
except json.JSONDecodeError:
|
|
1139
|
+
self.logger.warning(f"Invalid X-Pagination header: {x_pagination}")
|
|
1140
|
+
|
|
1141
|
+
async def _log_response_metrics(self, response: httpx.Response) -> None:
|
|
1142
|
+
"""Log response metrics for observability."""
|
|
1143
|
+
# Extract timing info if available (after response is read)
|
|
1144
|
+
try:
|
|
1145
|
+
if hasattr(response, "elapsed"):
|
|
1146
|
+
duration = response.elapsed.total_seconds()
|
|
1147
|
+
else:
|
|
1148
|
+
duration = 0.0
|
|
1149
|
+
except RuntimeError:
|
|
1150
|
+
# elapsed not available yet
|
|
1151
|
+
duration = 0.0
|
|
1152
|
+
|
|
1153
|
+
self.logger.debug(
|
|
1154
|
+
f"Response: {response.status_code} {response.request.method} "
|
|
1155
|
+
f"{_sanitize_url(str(response.request.url))} ({duration:.2f}s)"
|
|
1156
|
+
)
|