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.
Files changed (40) hide show
  1. pfmsoft/api_request/__init__.py +37 -0
  2. pfmsoft/api_request/cache/__init__.py +36 -0
  3. pfmsoft/api_request/cache/memory_cache.py +177 -0
  4. pfmsoft/api_request/cache/metadata_helpers.py +55 -0
  5. pfmsoft/api_request/cache/models.py +83 -0
  6. pfmsoft/api_request/cache/protocols.py +166 -0
  7. pfmsoft/api_request/cache/sqlite_cache/__init__.py +7 -0
  8. pfmsoft/api_request/cache/sqlite_cache/connection_helpers.py +126 -0
  9. pfmsoft/api_request/cache/sqlite_cache/query_helpers.py +107 -0
  10. pfmsoft/api_request/cache/sqlite_cache/sqlite_cache.py +233 -0
  11. pfmsoft/api_request/cache/sqlite_cache/table_definitions.sql +23 -0
  12. pfmsoft/api_request/cli/__init__.py +17 -0
  13. pfmsoft/api_request/cli/cache/__init__.py +12 -0
  14. pfmsoft/api_request/cli/cache/info.py +11 -0
  15. pfmsoft/api_request/cli/helpers.py +41 -0
  16. pfmsoft/api_request/cli/main_typer.py +52 -0
  17. pfmsoft/api_request/cli/request/__init__.py +11 -0
  18. pfmsoft/api_request/cli/request/request.py +222 -0
  19. pfmsoft/api_request/cli/request/validate.py +11 -0
  20. pfmsoft/api_request/helpers/http_session_factory.py +80 -0
  21. pfmsoft/api_request/helpers/json_io.py +256 -0
  22. pfmsoft/api_request/helpers/save_text_file.py +43 -0
  23. pfmsoft/api_request/helpers/whenever/__init__.py +4 -0
  24. pfmsoft/api_request/helpers/whenever/instant_helpers.py +41 -0
  25. pfmsoft/api_request/logging_config.py +202 -0
  26. pfmsoft/api_request/py.typed +0 -0
  27. pfmsoft/api_request/rate_limit/__init__.py +24 -0
  28. pfmsoft/api_request/rate_limit/aio_limiter.py +89 -0
  29. pfmsoft/api_request/rate_limit/protocols.py +101 -0
  30. pfmsoft/api_request/request/__init__.py +11 -0
  31. pfmsoft/api_request/request/api_requester.py +681 -0
  32. pfmsoft/api_request/request/intermediate_models.py +143 -0
  33. pfmsoft/api_request/request/models.py +355 -0
  34. pfmsoft/api_request/request/protocols.py +54 -0
  35. pfmsoft/api_request/settings.py +65 -0
  36. pfmsoft_api_request-0.1.3.dist-info/METADATA +171 -0
  37. pfmsoft_api_request-0.1.3.dist-info/RECORD +40 -0
  38. pfmsoft_api_request-0.1.3.dist-info/WHEEL +4 -0
  39. pfmsoft_api_request-0.1.3.dist-info/entry_points.txt +3 -0
  40. 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