pfmsoft-api-request 0.1.3__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.
- pfmsoft/api_request/__init__.py +37 -0
- pfmsoft/api_request/cache/__init__.py +36 -0
- pfmsoft/api_request/cache/memory_cache.py +177 -0
- pfmsoft/api_request/cache/metadata_helpers.py +55 -0
- pfmsoft/api_request/cache/models.py +83 -0
- pfmsoft/api_request/cache/protocols.py +166 -0
- pfmsoft/api_request/cache/sqlite_cache/__init__.py +7 -0
- pfmsoft/api_request/cache/sqlite_cache/connection_helpers.py +126 -0
- pfmsoft/api_request/cache/sqlite_cache/query_helpers.py +107 -0
- pfmsoft/api_request/cache/sqlite_cache/sqlite_cache.py +233 -0
- pfmsoft/api_request/cache/sqlite_cache/table_definitions.sql +23 -0
- pfmsoft/api_request/cli/__init__.py +17 -0
- pfmsoft/api_request/cli/cache/__init__.py +12 -0
- pfmsoft/api_request/cli/cache/info.py +11 -0
- pfmsoft/api_request/cli/helpers.py +41 -0
- pfmsoft/api_request/cli/main_typer.py +52 -0
- pfmsoft/api_request/cli/request/__init__.py +11 -0
- pfmsoft/api_request/cli/request/request.py +222 -0
- pfmsoft/api_request/cli/request/validate.py +11 -0
- pfmsoft/api_request/helpers/http_session_factory.py +80 -0
- pfmsoft/api_request/helpers/json_io.py +256 -0
- pfmsoft/api_request/helpers/save_text_file.py +43 -0
- pfmsoft/api_request/helpers/whenever/__init__.py +4 -0
- pfmsoft/api_request/helpers/whenever/instant_helpers.py +41 -0
- pfmsoft/api_request/logging_config.py +202 -0
- pfmsoft/api_request/py.typed +0 -0
- pfmsoft/api_request/rate_limit/__init__.py +24 -0
- pfmsoft/api_request/rate_limit/aio_limiter.py +89 -0
- pfmsoft/api_request/rate_limit/protocols.py +101 -0
- pfmsoft/api_request/request/__init__.py +11 -0
- pfmsoft/api_request/request/api_requester.py +681 -0
- pfmsoft/api_request/request/intermediate_models.py +143 -0
- pfmsoft/api_request/request/models.py +355 -0
- pfmsoft/api_request/request/protocols.py +54 -0
- pfmsoft/api_request/settings.py +65 -0
- pfmsoft_api_request-0.1.3.dist-info/METADATA +171 -0
- pfmsoft_api_request-0.1.3.dist-info/RECORD +40 -0
- pfmsoft_api_request-0.1.3.dist-info/WHEEL +4 -0
- pfmsoft_api_request-0.1.3.dist-info/entry_points.txt +3 -0
- pfmsoft_api_request-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
"""Request orchestration module for batched API execution.
|
|
2
|
+
|
|
3
|
+
This module is the request-processing boundary for the package. It is responsible
|
|
4
|
+
for orchestrating cache access, rate-limited network execution, pagination, and
|
|
5
|
+
normalization into public response models.
|
|
6
|
+
|
|
7
|
+
Operational contracts:
|
|
8
|
+
|
|
9
|
+
1. Context lifecycle:
|
|
10
|
+
- `ApiRequester` must be used as an async context manager.
|
|
11
|
+
- Entering the context initializes HTTP client, cache, and rate limiter.
|
|
12
|
+
- Exiting the context always closes cache and HTTP client resources.
|
|
13
|
+
|
|
14
|
+
2. Batch semantics:
|
|
15
|
+
- `process_requests` accepts a mapping of request ids to `Request` instances.
|
|
16
|
+
- Results are returned as a mapping with the same request ids.
|
|
17
|
+
- A request-level failure should not fail unrelated requests unless the failure
|
|
18
|
+
is classified as fatal.
|
|
19
|
+
|
|
20
|
+
3. Failure policy:
|
|
21
|
+
- Mixed failure mode is used.
|
|
22
|
+
- Non-success HTTP responses are returned as request-scoped
|
|
23
|
+
`FailedResponse` values by default.
|
|
24
|
+
- Fatal infrastructure/configuration failures raise and abort batch
|
|
25
|
+
processing.
|
|
26
|
+
- Optional `force_fail_on` status codes trigger a shared fail flag that
|
|
27
|
+
causes subsequent/in-flight requests to short-circuit as
|
|
28
|
+
request-level failures.
|
|
29
|
+
|
|
30
|
+
4. HTTP status handling:
|
|
31
|
+
- Success classification is implemented with `match` for easy policy changes.
|
|
32
|
+
- In the initial policy, only 2xx status codes are treated as successful
|
|
33
|
+
terminal responses.
|
|
34
|
+
- 304 is handled as a special cache-revalidation outcome for stale cache flows.
|
|
35
|
+
|
|
36
|
+
5. Cache behavior:
|
|
37
|
+
- Requests with `cache_key is None` bypass cache.
|
|
38
|
+
- Requests with `cache_key` present are cacheable and may return from cache.
|
|
39
|
+
- Stale cached entries are revalidated with conditional request headers.
|
|
40
|
+
- Revalidation should send both `If-None-Match` and `If-Modified-Since` when
|
|
41
|
+
source metadata is available.
|
|
42
|
+
- 304 on revalidation keeps cached body and refreshes metadata/expiry.
|
|
43
|
+
- 200 on revalidation replaces cached body and metadata.
|
|
44
|
+
|
|
45
|
+
6. Rate limiting:
|
|
46
|
+
- Every network call is executed under `rate_limiter.limit(...)`.
|
|
47
|
+
- Subject selection for limiter grouping is derived from request data and may
|
|
48
|
+
include `None` depending on protocol evolution.
|
|
49
|
+
|
|
50
|
+
7. Pagination:
|
|
51
|
+
- Pagination detection is metadata-driven (`X-Pages` semantics).
|
|
52
|
+
- Additional pages are fetched only for successful page-eligible responses.
|
|
53
|
+
- Page-body merge is delegated to a dedicated helper function so merge
|
|
54
|
+
behavior can be replaced independently.
|
|
55
|
+
- Initial merge policy concatenates JSON list payloads.
|
|
56
|
+
- If source consistency checks fail during paging, the request is failed.
|
|
57
|
+
|
|
58
|
+
8. Source consistency checks:
|
|
59
|
+
- Paged responses must be checked for source changes using response metadata
|
|
60
|
+
(for example `Last-Modified` drift).
|
|
61
|
+
- Source drift is treated as a request-level failure to avoid mixed snapshots.
|
|
62
|
+
|
|
63
|
+
9. Intermediate model usage:
|
|
64
|
+
- Internal `_Intermediate*` models are implementation details and not part of
|
|
65
|
+
the public package API.
|
|
66
|
+
- Public output is always normalized to `Response` or `FailedResponse`.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
import asyncio
|
|
70
|
+
import logging
|
|
71
|
+
from dataclasses import replace
|
|
72
|
+
from time import perf_counter
|
|
73
|
+
from types import TracebackType
|
|
74
|
+
from typing import Any, Self, cast
|
|
75
|
+
from uuid import UUID
|
|
76
|
+
|
|
77
|
+
from httpx2 import AsyncClient
|
|
78
|
+
from httpx2 import Response as HttpResponse
|
|
79
|
+
from whenever import Instant
|
|
80
|
+
|
|
81
|
+
from pfmsoft.api_request.cache.models import CachedResponse
|
|
82
|
+
from pfmsoft.api_request.cache.protocols import CacheFactory, CacheProtocol
|
|
83
|
+
from pfmsoft.api_request.helpers import json_io
|
|
84
|
+
from pfmsoft.api_request.helpers.http_session_factory import config_async_http_client
|
|
85
|
+
from pfmsoft.api_request.rate_limit.protocols import (
|
|
86
|
+
RateLimiterFactoryProtocol,
|
|
87
|
+
RateLimiterProtocol,
|
|
88
|
+
)
|
|
89
|
+
from pfmsoft.api_request.request.intermediate_models import (
|
|
90
|
+
CachableRequest,
|
|
91
|
+
FailedRequestBase,
|
|
92
|
+
FailNoResponse,
|
|
93
|
+
FailWithResponse,
|
|
94
|
+
IntermediateResponseBase,
|
|
95
|
+
RequestFromStaleCache,
|
|
96
|
+
Response200FromStaleCache,
|
|
97
|
+
Response304FromStaleCache,
|
|
98
|
+
SuccessfulResponse,
|
|
99
|
+
SuccessfulResponseBase,
|
|
100
|
+
UnprocessedRequest,
|
|
101
|
+
)
|
|
102
|
+
from pfmsoft.api_request.request.models import (
|
|
103
|
+
FailedResponse,
|
|
104
|
+
Request,
|
|
105
|
+
Requests,
|
|
106
|
+
Response,
|
|
107
|
+
ResponseMetadata,
|
|
108
|
+
ResponseMetadataRoot,
|
|
109
|
+
Responses,
|
|
110
|
+
Source,
|
|
111
|
+
)
|
|
112
|
+
from pfmsoft.api_request.request.protocols import (
|
|
113
|
+
ApiRequesterProtocol,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
logger = logging.getLogger(__name__)
|
|
117
|
+
logger.addHandler(logging.NullHandler())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ForcedFailureError(Exception):
|
|
121
|
+
"""Internal control-flow error used by forced-failure signaling.
|
|
122
|
+
|
|
123
|
+
This exception communicates that request processing should short-circuit
|
|
124
|
+
because a force-fail status condition was reached.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
request: Request,
|
|
130
|
+
response_json: Any | None = None,
|
|
131
|
+
response_metadata: ResponseMetadata | None = None,
|
|
132
|
+
from_flag: bool = False,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Initialize forced-failure error state with request context."""
|
|
135
|
+
self.request = request
|
|
136
|
+
self.response_json = response_json
|
|
137
|
+
self.response_metadata = response_metadata
|
|
138
|
+
self.from_other_failure_flag = from_flag
|
|
139
|
+
if self.from_other_failure_flag:
|
|
140
|
+
super().__init__(
|
|
141
|
+
f"ForcedFailureError: Forced failure triggered for request "
|
|
142
|
+
f"{request.request_key} due to failure of another request."
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
super().__init__(
|
|
146
|
+
f"ForcedFailureError: Forced failure triggered for request "
|
|
147
|
+
f"{request.request_key} with response status code: "
|
|
148
|
+
f"{response_metadata.status_code if response_metadata else None}."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ApiRequester(ApiRequesterProtocol):
|
|
153
|
+
"""Orchestrates cache-aware and rate-limited API request execution."""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
cache_factory: CacheFactory,
|
|
158
|
+
rate_limiter_factory: RateLimiterFactoryProtocol,
|
|
159
|
+
force_fail_on: set[int] | None = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Initialize requester dependencies and optional forced-failure policy.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
cache_factory: Callable that builds one cache instance per requester
|
|
165
|
+
context.
|
|
166
|
+
rate_limiter_factory: Callable that builds one rate-limiter instance
|
|
167
|
+
per requester context.
|
|
168
|
+
force_fail_on: Optional set of HTTP status codes that trigger the
|
|
169
|
+
shared forced-failure flag.
|
|
170
|
+
"""
|
|
171
|
+
self._client: AsyncClient | None = None
|
|
172
|
+
self._cache: CacheProtocol | None = None
|
|
173
|
+
self._rate_limit: RateLimiterProtocol | None = None
|
|
174
|
+
self._cache_factory = cache_factory
|
|
175
|
+
self._rate_limiter_factory = rate_limiter_factory
|
|
176
|
+
self._force_fail_on = force_fail_on or set()
|
|
177
|
+
"""A set of HTTP status codes that will trigger a forced failure for the entire batch."""
|
|
178
|
+
self._force_failure: bool = False
|
|
179
|
+
|
|
180
|
+
def _check_force_failure_flag(self, request: Request) -> None:
|
|
181
|
+
"""Raise forced-failure control-flow error when shared flag is set."""
|
|
182
|
+
if self._force_failure:
|
|
183
|
+
raise ForcedFailureError(request=request, from_flag=True)
|
|
184
|
+
|
|
185
|
+
def _check_response_for_force_failure(
|
|
186
|
+
self, request: Request, response_json: Any, response_metadata: ResponseMetadata
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Activate forced-failure signaling when configured status is observed."""
|
|
189
|
+
if response_metadata.status_code in self._force_fail_on:
|
|
190
|
+
self._force_failure = True
|
|
191
|
+
raise ForcedFailureError(
|
|
192
|
+
request=request,
|
|
193
|
+
response_json=response_json,
|
|
194
|
+
response_metadata=response_metadata,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def _is_success_status(cls, status_code: int) -> bool:
|
|
199
|
+
"""Return True when a status code is a terminal success for normal flow.
|
|
200
|
+
|
|
201
|
+
This function intentionally uses `match` so policy can be altered with
|
|
202
|
+
localized edits.
|
|
203
|
+
"""
|
|
204
|
+
match status_code:
|
|
205
|
+
case code if 200 <= code < 300:
|
|
206
|
+
return True
|
|
207
|
+
case _:
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
def _is_fatal_exception(self, exc: Exception) -> bool:
|
|
211
|
+
"""Return True when an exception should fail-fast the whole batch.
|
|
212
|
+
|
|
213
|
+
Fatal errors represent infrastructure/configuration/programming defects.
|
|
214
|
+
Request-scoped network and HTTP problems should be converted to
|
|
215
|
+
request-level failures instead.
|
|
216
|
+
"""
|
|
217
|
+
match exc:
|
|
218
|
+
case RuntimeError():
|
|
219
|
+
self._force_failure = True
|
|
220
|
+
return True
|
|
221
|
+
case _:
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _http_response_to_metadata(response: HttpResponse) -> ResponseMetadata:
|
|
226
|
+
"""Convert an HTTP client response to package response metadata."""
|
|
227
|
+
bytes_downloaded = getattr(
|
|
228
|
+
response,
|
|
229
|
+
"num_bytes_downloaded",
|
|
230
|
+
len(getattr(response, "content", b"")),
|
|
231
|
+
)
|
|
232
|
+
return ResponseMetadata(
|
|
233
|
+
status_code=response.status_code,
|
|
234
|
+
reason_phrase=response.reason_phrase,
|
|
235
|
+
url=str(response.url),
|
|
236
|
+
elapsed=int(response.elapsed.total_seconds() * 1_000_000),
|
|
237
|
+
bytes_downloaded=bytes_downloaded,
|
|
238
|
+
headers=tuple(response.headers.items()),
|
|
239
|
+
received_timestamp=Instant.now().timestamp_nanos(),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _cached_metadata(cached_response: CachedResponse) -> ResponseMetadata:
|
|
244
|
+
"""Decode cached metadata JSON text into a ResponseMetadata model."""
|
|
245
|
+
return ResponseMetadataRoot.model_validate_json(
|
|
246
|
+
cached_response.response_metadata_json
|
|
247
|
+
).root
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _updated_request_headers(request: Request, **headers: str) -> Request:
|
|
251
|
+
"""Return a copy of request with additional/overridden headers."""
|
|
252
|
+
merged_headers = dict(request.headers)
|
|
253
|
+
merged_headers.update(headers)
|
|
254
|
+
return replace(request, headers=merged_headers)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _merge_paged_json_lists(first_json: Any, other_jsons: list[Any]) -> list[Any]:
|
|
258
|
+
"""Merge paged JSON list payloads by concatenating array elements."""
|
|
259
|
+
merged: list[object] = []
|
|
260
|
+
all_payloads = [first_json, *other_jsons]
|
|
261
|
+
for payload in all_payloads:
|
|
262
|
+
if not isinstance(payload, list):
|
|
263
|
+
raise ValueError("Paged response body is not a JSON list")
|
|
264
|
+
merged.extend(cast(list[object], payload))
|
|
265
|
+
return merged
|
|
266
|
+
|
|
267
|
+
async def __aenter__(self) -> Self:
|
|
268
|
+
"""Enter context and initialize HTTP client, cache, and rate limiter."""
|
|
269
|
+
self._client = await config_async_http_client()
|
|
270
|
+
self._cache = self._cache_factory()
|
|
271
|
+
await self._cache.__aenter__()
|
|
272
|
+
self._rate_limit = self._rate_limiter_factory()
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
async def __aexit__(
|
|
276
|
+
self,
|
|
277
|
+
exc_type: type[BaseException] | None,
|
|
278
|
+
exc_value: BaseException | None,
|
|
279
|
+
traceback: TracebackType | None,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Exit context and release managed resources."""
|
|
282
|
+
if self._cache is not None:
|
|
283
|
+
await self._cache.__aexit__(exc_type, exc_value, traceback)
|
|
284
|
+
self._cache = None
|
|
285
|
+
if self._client is not None:
|
|
286
|
+
await self._client.aclose()
|
|
287
|
+
self._client = None
|
|
288
|
+
|
|
289
|
+
async def _session_check(self) -> AsyncClient:
|
|
290
|
+
"""Check if the HTTP client is initialized and return it."""
|
|
291
|
+
if self._client is None:
|
|
292
|
+
raise RuntimeError(
|
|
293
|
+
"HTTP client is not initialized. Use 'async with ApiRequest()' to initialize."
|
|
294
|
+
)
|
|
295
|
+
return self._client
|
|
296
|
+
|
|
297
|
+
async def _cache_check(self) -> CacheProtocol:
|
|
298
|
+
"""Check if the cache is initialized and return it."""
|
|
299
|
+
if self._cache is None:
|
|
300
|
+
raise RuntimeError(
|
|
301
|
+
"Cache is not initialized. Use 'async with ApiRequest()' to initialize."
|
|
302
|
+
)
|
|
303
|
+
return self._cache
|
|
304
|
+
|
|
305
|
+
async def _rate_limit_check(self) -> RateLimiterProtocol:
|
|
306
|
+
"""Check if the rate limiter is initialized and return it."""
|
|
307
|
+
if self._rate_limit is None:
|
|
308
|
+
raise RuntimeError(
|
|
309
|
+
"Rate limiter is not initialized. Use 'async with ApiRequest()' to initialize."
|
|
310
|
+
)
|
|
311
|
+
return self._rate_limit
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
async def cache(self) -> CacheProtocol:
|
|
315
|
+
"""Get the cache instance."""
|
|
316
|
+
return await self._cache_check()
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
async def rate_limiter(self) -> RateLimiterProtocol:
|
|
320
|
+
"""Get the rate limiter instance."""
|
|
321
|
+
return await self._rate_limit_check()
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
async def session(self) -> AsyncClient:
|
|
325
|
+
"""Get the HTTP client instance."""
|
|
326
|
+
return await self._session_check()
|
|
327
|
+
|
|
328
|
+
async def process_requests(
|
|
329
|
+
self,
|
|
330
|
+
requests: Requests,
|
|
331
|
+
purge_secrets: bool = True,
|
|
332
|
+
) -> Responses:
|
|
333
|
+
"""Process a request batch and normalize outcomes into public models.
|
|
334
|
+
|
|
335
|
+
The input mapping keys are preserved across output `successful` and
|
|
336
|
+
`failed` maps.
|
|
337
|
+
|
|
338
|
+
Access tokens are optionally purged from all responses for security purposes
|
|
339
|
+
before returning.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
requests: Mapping of request ids to `Request` instances.
|
|
343
|
+
purge_secrets: Whether to purge access tokens from response metadata
|
|
344
|
+
before returning. Defaults to True.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Responses: A mapping of request ids to either `Response` or `FailedResponse`
|
|
348
|
+
instances.
|
|
349
|
+
"""
|
|
350
|
+
self._force_failure = False
|
|
351
|
+
intermediate_responses = await self._dispatch_requests(requests)
|
|
352
|
+
|
|
353
|
+
successful: dict[UUID, Response] = {}
|
|
354
|
+
failed: dict[UUID, FailedResponse] = {}
|
|
355
|
+
for request_id, intermediate in intermediate_responses.items():
|
|
356
|
+
match intermediate:
|
|
357
|
+
case SuccessfulResponseBase():
|
|
358
|
+
response = Response(
|
|
359
|
+
metadata=intermediate.metadata,
|
|
360
|
+
json=intermediate.json,
|
|
361
|
+
request=intermediate.request,
|
|
362
|
+
source=intermediate.source,
|
|
363
|
+
)
|
|
364
|
+
self._check_purge_secrets(purge_secrets, response)
|
|
365
|
+
successful[request_id] = response
|
|
366
|
+
|
|
367
|
+
case FailWithResponse():
|
|
368
|
+
response = FailedResponse(
|
|
369
|
+
metadata=intermediate.metadata,
|
|
370
|
+
json=intermediate.json,
|
|
371
|
+
request=intermediate.request,
|
|
372
|
+
error_messages=[
|
|
373
|
+
f"HTTP {intermediate.metadata.status_code} {intermediate.metadata.reason_phrase}"
|
|
374
|
+
],
|
|
375
|
+
)
|
|
376
|
+
self._check_purge_secrets(purge_secrets, response)
|
|
377
|
+
failed[request_id] = response
|
|
378
|
+
case FailNoResponse():
|
|
379
|
+
response = FailedResponse(
|
|
380
|
+
request=intermediate.request,
|
|
381
|
+
error_messages=[intermediate.error_message],
|
|
382
|
+
)
|
|
383
|
+
self._check_purge_secrets(purge_secrets, response)
|
|
384
|
+
failed[request_id] = response
|
|
385
|
+
case _:
|
|
386
|
+
response = FailedResponse(
|
|
387
|
+
request=requests[request_id],
|
|
388
|
+
error_messages=["Unknown intermediate response type"],
|
|
389
|
+
)
|
|
390
|
+
self._check_purge_secrets(purge_secrets, response)
|
|
391
|
+
failed[request_id] = response
|
|
392
|
+
return Responses(successful=successful, failed=failed)
|
|
393
|
+
|
|
394
|
+
def _check_purge_secrets(
|
|
395
|
+
self, do_purge: bool, response: Response | FailedResponse
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Purge access tokens from response metadata for security purposes."""
|
|
398
|
+
if do_purge:
|
|
399
|
+
response.purge_secrets()
|
|
400
|
+
|
|
401
|
+
async def _dispatch_requests(
|
|
402
|
+
self,
|
|
403
|
+
requests: Requests,
|
|
404
|
+
) -> dict[UUID, IntermediateResponseBase]:
|
|
405
|
+
"""Dispatch batch requests concurrently and collect intermediate results."""
|
|
406
|
+
|
|
407
|
+
async def execute(request: Request) -> IntermediateResponseBase:
|
|
408
|
+
try:
|
|
409
|
+
if request.cache_key is None:
|
|
410
|
+
return await self._http_request(UnprocessedRequest(request=request))
|
|
411
|
+
return await self._cacheable_request(
|
|
412
|
+
CachableRequest(request=request, cache_key=request.cache_key)
|
|
413
|
+
)
|
|
414
|
+
except Exception as exc: # noqa: BLE001
|
|
415
|
+
if self._is_fatal_exception(exc):
|
|
416
|
+
raise
|
|
417
|
+
return FailNoResponse(request=request, error_message=str(exc))
|
|
418
|
+
|
|
419
|
+
task_keys = list(requests.keys())
|
|
420
|
+
task_values = [asyncio.create_task(execute(requests[k])) for k in task_keys]
|
|
421
|
+
results = await asyncio.gather(*task_values)
|
|
422
|
+
return {task_keys[i]: results[i] for i in range(len(task_keys))}
|
|
423
|
+
|
|
424
|
+
async def _http_request(
|
|
425
|
+
self,
|
|
426
|
+
request: UnprocessedRequest,
|
|
427
|
+
*,
|
|
428
|
+
allow_pagination: bool = True,
|
|
429
|
+
expected_success_statuses: set[int] | None = None,
|
|
430
|
+
) -> SuccessfulResponseBase | FailedRequestBase:
|
|
431
|
+
"""Perform one network request and classify the intermediate outcome.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
request: Request wrapper to execute.
|
|
435
|
+
allow_pagination: Whether page fan-out is allowed for successful
|
|
436
|
+
page-eligible responses.
|
|
437
|
+
expected_success_statuses: Optional explicit success status override
|
|
438
|
+
set (for example `{304}` during stale-cache revalidation).
|
|
439
|
+
"""
|
|
440
|
+
session = await self.session
|
|
441
|
+
rate_limiter = await self.rate_limiter
|
|
442
|
+
outgoing_headers = request.request.headers
|
|
443
|
+
if isinstance(request, RequestFromStaleCache):
|
|
444
|
+
outgoing_headers = {
|
|
445
|
+
**request.request.headers,
|
|
446
|
+
**request.conditional_headers,
|
|
447
|
+
}
|
|
448
|
+
try:
|
|
449
|
+
# Check for forced failure before making the request
|
|
450
|
+
self._check_force_failure_flag(request.request)
|
|
451
|
+
async with rate_limiter.limit(request.request.rate_key):
|
|
452
|
+
# Check for forced failure after passing the rate limit gate
|
|
453
|
+
self._check_force_failure_flag(request.request)
|
|
454
|
+
http_response = await session.request(
|
|
455
|
+
method=request.request.method,
|
|
456
|
+
url=request.request.url,
|
|
457
|
+
headers=outgoing_headers,
|
|
458
|
+
params=request.request.parameters,
|
|
459
|
+
json=request.request.body,
|
|
460
|
+
)
|
|
461
|
+
except Exception as exc: # noqa: BLE001
|
|
462
|
+
if self._is_fatal_exception(exc):
|
|
463
|
+
raise
|
|
464
|
+
return FailNoResponse(request=request.request, error_message=str(exc))
|
|
465
|
+
|
|
466
|
+
metadata = self._http_response_to_metadata(http_response)
|
|
467
|
+
response_text = http_response.text
|
|
468
|
+
parsed_json: Any | None = None
|
|
469
|
+
if response_text != "":
|
|
470
|
+
try:
|
|
471
|
+
parsed_json = json_io.json_loads(response_text)
|
|
472
|
+
except Exception as exc: # noqa: BLE001
|
|
473
|
+
return FailNoResponse(
|
|
474
|
+
request=request.request,
|
|
475
|
+
error_message=f"Failed to parse response JSON: {exc}",
|
|
476
|
+
)
|
|
477
|
+
status_code = metadata.status_code
|
|
478
|
+
|
|
479
|
+
is_explicit_success = (
|
|
480
|
+
expected_success_statuses is not None
|
|
481
|
+
and status_code in expected_success_statuses
|
|
482
|
+
)
|
|
483
|
+
if is_explicit_success or self._is_success_status(status_code):
|
|
484
|
+
success = SuccessfulResponse(
|
|
485
|
+
request=request.request,
|
|
486
|
+
metadata=metadata,
|
|
487
|
+
json=parsed_json,
|
|
488
|
+
source=Source.NETWORK,
|
|
489
|
+
)
|
|
490
|
+
if (
|
|
491
|
+
allow_pagination
|
|
492
|
+
and self._is_success_status(status_code)
|
|
493
|
+
and self._is_paged_response(success)
|
|
494
|
+
):
|
|
495
|
+
return await self._handle_paged_response(success)
|
|
496
|
+
return success
|
|
497
|
+
# Check for the need for forced failure based on the response status code and
|
|
498
|
+
# the configured force_fail_on set
|
|
499
|
+
self._check_response_for_force_failure(
|
|
500
|
+
request.request, response_json=parsed_json, response_metadata=metadata
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
return FailWithResponse(
|
|
504
|
+
request=request.request,
|
|
505
|
+
metadata=metadata,
|
|
506
|
+
json=parsed_json,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
async def _cacheable_request(
|
|
510
|
+
self, request: CachableRequest
|
|
511
|
+
) -> SuccessfulResponseBase | FailedRequestBase:
|
|
512
|
+
"""Perform one cache-aware request flow.
|
|
513
|
+
|
|
514
|
+
Behavior:
|
|
515
|
+
- Missing cache entry: execute network request and cache success.
|
|
516
|
+
- Fresh cache entry: return cached body/metadata.
|
|
517
|
+
- Stale cache entry: revalidate with conditional headers and update
|
|
518
|
+
cache on `304` or `200` outcomes.
|
|
519
|
+
"""
|
|
520
|
+
cache = await self.cache
|
|
521
|
+
cached_response = await cache.get(request.cache_key)
|
|
522
|
+
|
|
523
|
+
if cached_response is None:
|
|
524
|
+
response = await self._http_request(
|
|
525
|
+
UnprocessedRequest(request=request.request),
|
|
526
|
+
)
|
|
527
|
+
if isinstance(response, SuccessfulResponseBase):
|
|
528
|
+
await cache.set(
|
|
529
|
+
request.cache_key,
|
|
530
|
+
json_io.json_dumps(response.json),
|
|
531
|
+
response.metadata,
|
|
532
|
+
)
|
|
533
|
+
return response
|
|
534
|
+
|
|
535
|
+
cached_metadata = self._cached_metadata(cached_response)
|
|
536
|
+
if not cached_response.is_expired:
|
|
537
|
+
return SuccessfulResponse(
|
|
538
|
+
request=request.request,
|
|
539
|
+
metadata=cached_metadata,
|
|
540
|
+
json=json_io.json_loads(cached_response.response_text),
|
|
541
|
+
source=Source.CACHE,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
stale_request = RequestFromStaleCache(
|
|
545
|
+
request=request.request,
|
|
546
|
+
cache_key=request.cache_key,
|
|
547
|
+
etag=cached_response.etag,
|
|
548
|
+
last_modified=cached_metadata.last_modified,
|
|
549
|
+
)
|
|
550
|
+
refreshed = await self._http_request(
|
|
551
|
+
stale_request,
|
|
552
|
+
allow_pagination=True,
|
|
553
|
+
expected_success_statuses={200, 304},
|
|
554
|
+
)
|
|
555
|
+
if isinstance(refreshed, FailedRequestBase):
|
|
556
|
+
return refreshed
|
|
557
|
+
|
|
558
|
+
match refreshed.metadata.status_code:
|
|
559
|
+
case 304:
|
|
560
|
+
refreshed_cached_response = await cache.update_304(
|
|
561
|
+
request.cache_key,
|
|
562
|
+
refreshed.metadata,
|
|
563
|
+
)
|
|
564
|
+
return Response304FromStaleCache(
|
|
565
|
+
request=request.request,
|
|
566
|
+
metadata=self._cached_metadata(refreshed_cached_response),
|
|
567
|
+
json=json_io.json_loads(refreshed_cached_response.response_text),
|
|
568
|
+
)
|
|
569
|
+
case 200:
|
|
570
|
+
await cache.set(
|
|
571
|
+
request.cache_key,
|
|
572
|
+
json_io.json_dumps(refreshed.json),
|
|
573
|
+
refreshed.metadata,
|
|
574
|
+
)
|
|
575
|
+
return Response200FromStaleCache(
|
|
576
|
+
request=request.request,
|
|
577
|
+
metadata=refreshed.metadata,
|
|
578
|
+
json=refreshed.json,
|
|
579
|
+
)
|
|
580
|
+
case _:
|
|
581
|
+
return FailWithResponse(
|
|
582
|
+
request=request.request,
|
|
583
|
+
metadata=refreshed.metadata,
|
|
584
|
+
json=refreshed.json,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
def _is_paged_response(self, response: SuccessfulResponse) -> bool:
|
|
588
|
+
"""Return True when response is a successful multi-page payload."""
|
|
589
|
+
return response.metadata.status_code == 200 and response.metadata.pages > 1
|
|
590
|
+
|
|
591
|
+
async def _handle_paged_response(
|
|
592
|
+
self, response: SuccessfulResponse
|
|
593
|
+
) -> SuccessfulResponseBase | FailedRequestBase:
|
|
594
|
+
"""Collect additional pages and return a merged successful response.
|
|
595
|
+
|
|
596
|
+
If any page fetch fails, or source consistency checks fail, a
|
|
597
|
+
request-scoped failure is returned.
|
|
598
|
+
"""
|
|
599
|
+
total_pages = response.metadata.pages
|
|
600
|
+
if total_pages <= 1:
|
|
601
|
+
return response
|
|
602
|
+
|
|
603
|
+
paged_responses: list[SuccessfulResponseBase] = []
|
|
604
|
+
pages_start = perf_counter()
|
|
605
|
+
page_responses = await self._gather_paged_responses(response)
|
|
606
|
+
pages_end = perf_counter()
|
|
607
|
+
logger.info(
|
|
608
|
+
"Fetched %d pages for request %s in %.2f seconds",
|
|
609
|
+
total_pages,
|
|
610
|
+
response.request.url,
|
|
611
|
+
pages_end - pages_start,
|
|
612
|
+
)
|
|
613
|
+
for page_response in page_responses:
|
|
614
|
+
if isinstance(page_response, FailedRequestBase):
|
|
615
|
+
return page_response
|
|
616
|
+
paged_responses.append(page_response)
|
|
617
|
+
|
|
618
|
+
if self._has_source_changed(response, paged_responses):
|
|
619
|
+
return FailNoResponse(
|
|
620
|
+
request=response.request,
|
|
621
|
+
error_message=(
|
|
622
|
+
"Detected source data change while collecting paged response; "
|
|
623
|
+
"aborting merge"
|
|
624
|
+
),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
try:
|
|
628
|
+
merged_json = self._merge_paged_json_lists(
|
|
629
|
+
response.json,
|
|
630
|
+
[item.json for item in paged_responses],
|
|
631
|
+
)
|
|
632
|
+
except Exception as exc: # noqa: BLE001
|
|
633
|
+
return FailNoResponse(request=response.request, error_message=str(exc))
|
|
634
|
+
|
|
635
|
+
return SuccessfulResponse(
|
|
636
|
+
request=response.request,
|
|
637
|
+
metadata=response.metadata,
|
|
638
|
+
json=merged_json,
|
|
639
|
+
source=response.source,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
async def _gather_paged_responses(
|
|
643
|
+
self, response: SuccessfulResponse
|
|
644
|
+
) -> list[SuccessfulResponseBase | FailedRequestBase]:
|
|
645
|
+
"""Fetch page 2..N responses concurrently for a paged first response."""
|
|
646
|
+
tasks = (
|
|
647
|
+
self._http_request(
|
|
648
|
+
self._build_paged_request(response, page_number),
|
|
649
|
+
allow_pagination=False,
|
|
650
|
+
)
|
|
651
|
+
for page_number in range(2, response.metadata.pages + 1)
|
|
652
|
+
)
|
|
653
|
+
return await asyncio.gather(*tasks)
|
|
654
|
+
|
|
655
|
+
@staticmethod
|
|
656
|
+
def _build_paged_request(
|
|
657
|
+
response: SuccessfulResponse,
|
|
658
|
+
page_number: int,
|
|
659
|
+
) -> UnprocessedRequest:
|
|
660
|
+
"""Build one follow-up paged request by injecting `page` parameter."""
|
|
661
|
+
return UnprocessedRequest(
|
|
662
|
+
request=replace(
|
|
663
|
+
response.request,
|
|
664
|
+
parameters={
|
|
665
|
+
**response.request.parameters,
|
|
666
|
+
"page": page_number,
|
|
667
|
+
},
|
|
668
|
+
)
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
def _has_source_changed(
|
|
672
|
+
self,
|
|
673
|
+
first_response: SuccessfulResponseBase,
|
|
674
|
+
paged_responses: list[SuccessfulResponseBase],
|
|
675
|
+
) -> bool:
|
|
676
|
+
"""Check for paged-source drift using `Last-Modified` consistency."""
|
|
677
|
+
first_last_modified = first_response.metadata.last_modified
|
|
678
|
+
for response in paged_responses:
|
|
679
|
+
if response.metadata.last_modified != first_last_modified:
|
|
680
|
+
return True
|
|
681
|
+
return False
|