fleetpull 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.
- fleetpull/__init__.py +36 -0
- fleetpull/api/__init__.py +32 -0
- fleetpull/api/auth_ingress.py +216 -0
- fleetpull/api/catalog.py +139 -0
- fleetpull/api/fetch.py +229 -0
- fleetpull/api/identity.py +90 -0
- fleetpull/api/sync.py +735 -0
- fleetpull/cli.py +146 -0
- fleetpull/config/__init__.py +47 -0
- fleetpull/config/base.py +18 -0
- fleetpull/config/example.py +90 -0
- fleetpull/config/geotab.py +78 -0
- fleetpull/config/http.py +32 -0
- fleetpull/config/loading.py +195 -0
- fleetpull/config/logger.py +139 -0
- fleetpull/config/providers.py +520 -0
- fleetpull/config/rate_limit.py +50 -0
- fleetpull/config/resolution.py +156 -0
- fleetpull/config/retry.py +69 -0
- fleetpull/config/root.py +157 -0
- fleetpull/config/sections.py +146 -0
- fleetpull/endpoints/__init__.py +24 -0
- fleetpull/endpoints/geotab/__init__.py +12 -0
- fleetpull/endpoints/geotab/_requests.py +427 -0
- fleetpull/endpoints/geotab/annotation_logs.py +73 -0
- fleetpull/endpoints/geotab/audits.py +70 -0
- fleetpull/endpoints/geotab/devices.py +79 -0
- fleetpull/endpoints/geotab/driver_changes.py +73 -0
- fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
- fleetpull/endpoints/geotab/dvir_logs.py +76 -0
- fleetpull/endpoints/geotab/exception_events.py +119 -0
- fleetpull/endpoints/geotab/fault_data.py +72 -0
- fleetpull/endpoints/geotab/fill_ups.py +79 -0
- fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
- fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
- fleetpull/endpoints/geotab/log_records.py +74 -0
- fleetpull/endpoints/geotab/media_files.py +74 -0
- fleetpull/endpoints/geotab/shipment_logs.py +71 -0
- fleetpull/endpoints/geotab/status_data.py +71 -0
- fleetpull/endpoints/geotab/text_messages.py +77 -0
- fleetpull/endpoints/geotab/trips.py +102 -0
- fleetpull/endpoints/geotab/users.py +81 -0
- fleetpull/endpoints/motive/__init__.py +12 -0
- fleetpull/endpoints/motive/_spec_builders.py +94 -0
- fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
- fleetpull/endpoints/motive/driving_periods.py +85 -0
- fleetpull/endpoints/motive/groups.py +61 -0
- fleetpull/endpoints/motive/idle_events.py +94 -0
- fleetpull/endpoints/motive/users.py +64 -0
- fleetpull/endpoints/motive/vehicle_locations.py +170 -0
- fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
- fleetpull/endpoints/motive/vehicles.py +106 -0
- fleetpull/endpoints/registry.py +280 -0
- fleetpull/endpoints/samsara/__init__.py +12 -0
- fleetpull/endpoints/samsara/_spec_builders.py +202 -0
- fleetpull/endpoints/samsara/addresses.py +77 -0
- fleetpull/endpoints/samsara/asset_locations.py +217 -0
- fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
- fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
- fleetpull/endpoints/samsara/drivers.py +169 -0
- fleetpull/endpoints/samsara/engine_states.py +114 -0
- fleetpull/endpoints/samsara/gps_readings.py +113 -0
- fleetpull/endpoints/samsara/idling_events.py +195 -0
- fleetpull/endpoints/samsara/odometer_readings.py +116 -0
- fleetpull/endpoints/samsara/trips.py +217 -0
- fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
- fleetpull/endpoints/samsara/vehicles.py +121 -0
- fleetpull/endpoints/shared/__init__.py +57 -0
- fleetpull/endpoints/shared/base.py +529 -0
- fleetpull/endpoints/shared/request_shape.py +227 -0
- fleetpull/endpoints/shared/resume.py +74 -0
- fleetpull/endpoints/shared/spec_builders.py +59 -0
- fleetpull/endpoints/shared/sync_mode.py +159 -0
- fleetpull/endpoints/shared/url_paths.py +149 -0
- fleetpull/exceptions.py +320 -0
- fleetpull/incremental/__init__.py +27 -0
- fleetpull/incremental/cursor.py +66 -0
- fleetpull/incremental/resolution.py +152 -0
- fleetpull/incremental/seed.py +53 -0
- fleetpull/incremental/window.py +94 -0
- fleetpull/logger/__init__.py +5 -0
- fleetpull/logger/setup.py +145 -0
- fleetpull/model_contract/__init__.py +6 -0
- fleetpull/model_contract/coercions.py +33 -0
- fleetpull/model_contract/response.py +45 -0
- fleetpull/models/__init__.py +7 -0
- fleetpull/models/geotab/__init__.py +166 -0
- fleetpull/models/geotab/annotation_log.py +109 -0
- fleetpull/models/geotab/audit.py +56 -0
- fleetpull/models/geotab/device.py +217 -0
- fleetpull/models/geotab/driver_change.py +102 -0
- fleetpull/models/geotab/duty_status_log.py +200 -0
- fleetpull/models/geotab/dvir_log.py +184 -0
- fleetpull/models/geotab/exception_event.py +135 -0
- fleetpull/models/geotab/fault_data.py +168 -0
- fleetpull/models/geotab/fill_up.py +189 -0
- fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
- fleetpull/models/geotab/fuel_tax_detail.py +150 -0
- fleetpull/models/geotab/log_record.py +68 -0
- fleetpull/models/geotab/media_file.py +133 -0
- fleetpull/models/geotab/shared.py +221 -0
- fleetpull/models/geotab/shipment_log.py +105 -0
- fleetpull/models/geotab/status_data.py +108 -0
- fleetpull/models/geotab/text_message.py +125 -0
- fleetpull/models/geotab/trip.py +162 -0
- fleetpull/models/geotab/user.py +187 -0
- fleetpull/models/motive/__init__.py +43 -0
- fleetpull/models/motive/driver_idle_rollup.py +109 -0
- fleetpull/models/motive/driving_period.py +82 -0
- fleetpull/models/motive/group.py +49 -0
- fleetpull/models/motive/idle_event.py +77 -0
- fleetpull/models/motive/shared.py +192 -0
- fleetpull/models/motive/user.py +217 -0
- fleetpull/models/motive/vehicle.py +162 -0
- fleetpull/models/motive/vehicle_location.py +127 -0
- fleetpull/models/motive/vehicle_utilization.py +128 -0
- fleetpull/models/samsara/__init__.py +108 -0
- fleetpull/models/samsara/address.py +149 -0
- fleetpull/models/samsara/asset_location.py +131 -0
- fleetpull/models/samsara/driver.py +232 -0
- fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
- fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
- fleetpull/models/samsara/engine_state.py +92 -0
- fleetpull/models/samsara/gps_reading.py +146 -0
- fleetpull/models/samsara/idling_event.py +174 -0
- fleetpull/models/samsara/odometer_reading.py +90 -0
- fleetpull/models/samsara/trip.py +199 -0
- fleetpull/models/samsara/vehicle.py +167 -0
- fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
- fleetpull/network/__init__.py +9 -0
- fleetpull/network/auth/__init__.py +15 -0
- fleetpull/network/auth/authenticate.py +271 -0
- fleetpull/network/auth/manager.py +223 -0
- fleetpull/network/auth/models.py +57 -0
- fleetpull/network/auth/strategies.py +169 -0
- fleetpull/network/classifiers/__init__.py +11 -0
- fleetpull/network/classifiers/geotab.py +145 -0
- fleetpull/network/classifiers/motive.py +79 -0
- fleetpull/network/classifiers/samsara.py +80 -0
- fleetpull/network/client/__init__.py +25 -0
- fleetpull/network/client/page.py +32 -0
- fleetpull/network/client/profile.py +27 -0
- fleetpull/network/client/registry.py +103 -0
- fleetpull/network/client/registry_base.py +150 -0
- fleetpull/network/client/runtime.py +44 -0
- fleetpull/network/client/transport.py +381 -0
- fleetpull/network/contract/__init__.py +50 -0
- fleetpull/network/contract/auth.py +48 -0
- fleetpull/network/contract/classifier.py +189 -0
- fleetpull/network/contract/envelope_fetcher.py +33 -0
- fleetpull/network/contract/envelopes.py +189 -0
- fleetpull/network/contract/outcome.py +43 -0
- fleetpull/network/contract/page_decoder.py +101 -0
- fleetpull/network/contract/request.py +97 -0
- fleetpull/network/decoders/__init__.py +40 -0
- fleetpull/network/decoders/_window_stamp.py +78 -0
- fleetpull/network/decoders/geotab.py +309 -0
- fleetpull/network/decoders/motive.py +204 -0
- fleetpull/network/decoders/motive_reports.py +114 -0
- fleetpull/network/decoders/samsara.py +343 -0
- fleetpull/network/decoders/samsara_reports.py +119 -0
- fleetpull/network/decoders/single_page.py +55 -0
- fleetpull/network/limits/__init__.py +13 -0
- fleetpull/network/limits/bucket_math.py +58 -0
- fleetpull/network/limits/limiter.py +153 -0
- fleetpull/network/limits/registry.py +96 -0
- fleetpull/network/posture/__init__.py +6 -0
- fleetpull/network/posture/client_options.py +96 -0
- fleetpull/network/retry/__init__.py +13 -0
- fleetpull/network/retry/decision.py +155 -0
- fleetpull/network/tls/__init__.py +5 -0
- fleetpull/network/tls/truststore_context.py +35 -0
- fleetpull/orchestrator/__init__.py +30 -0
- fleetpull/orchestrator/backfill.py +127 -0
- fleetpull/orchestrator/batch.py +155 -0
- fleetpull/orchestrator/bisection.py +271 -0
- fleetpull/orchestrator/drivers.py +350 -0
- fleetpull/orchestrator/entry.py +347 -0
- fleetpull/orchestrator/executors.py +128 -0
- fleetpull/orchestrator/fanout.py +163 -0
- fleetpull/orchestrator/feed_drive.py +266 -0
- fleetpull/orchestrator/metadata_projection.py +195 -0
- fleetpull/orchestrator/outcome.py +53 -0
- fleetpull/orchestrator/recording.py +85 -0
- fleetpull/orchestrator/resume.py +143 -0
- fleetpull/orchestrator/roster_harvest.py +108 -0
- fleetpull/orchestrator/roster_refresh.py +363 -0
- fleetpull/orchestrator/runner.py +262 -0
- fleetpull/orchestrator/shape_resolution.py +221 -0
- fleetpull/orchestrator/spine.py +166 -0
- fleetpull/orchestrator/streaming.py +153 -0
- fleetpull/orchestrator/unit_loop.py +273 -0
- fleetpull/orchestrator/watermark_drive.py +455 -0
- fleetpull/paths/__init__.py +13 -0
- fleetpull/paths/datasets.py +38 -0
- fleetpull/paths/partitions.py +41 -0
- fleetpull/paths/resolution.py +98 -0
- fleetpull/polars_typing/__init__.py +20 -0
- fleetpull/py.typed +0 -0
- fleetpull/records/__init__.py +19 -0
- fleetpull/records/convert.py +53 -0
- fleetpull/records/dataframe.py +63 -0
- fleetpull/records/event_time.py +70 -0
- fleetpull/records/fields.py +190 -0
- fleetpull/records/flatten.py +54 -0
- fleetpull/records/roster_members.py +74 -0
- fleetpull/records/schema.py +73 -0
- fleetpull/records/validation.py +62 -0
- fleetpull/resources/__init__.py +10 -0
- fleetpull/resources/config.example.yaml +222 -0
- fleetpull/roster/__init__.py +16 -0
- fleetpull/roster/definition.py +46 -0
- fleetpull/roster/key.py +40 -0
- fleetpull/roster/registry.py +93 -0
- fleetpull/state/__init__.py +36 -0
- fleetpull/state/cursors.py +417 -0
- fleetpull/state/database.py +463 -0
- fleetpull/state/migrations.py +430 -0
- fleetpull/state/reconcile.py +127 -0
- fleetpull/state/rosters.py +159 -0
- fleetpull/state/run_ledger.py +572 -0
- fleetpull/state/work_units.py +648 -0
- fleetpull/storage/__init__.py +30 -0
- fleetpull/storage/append.py +187 -0
- fleetpull/storage/atomic.py +161 -0
- fleetpull/storage/files.py +176 -0
- fleetpull/storage/frames.py +98 -0
- fleetpull/storage/metadata.py +161 -0
- fleetpull/storage/partitioned.py +205 -0
- fleetpull/storage/pruning.py +169 -0
- fleetpull/storage/read.py +35 -0
- fleetpull/storage/result.py +34 -0
- fleetpull/storage/single_file.py +130 -0
- fleetpull/storage/splitting.py +88 -0
- fleetpull/storage/staging.py +178 -0
- fleetpull/storage/writers.py +145 -0
- fleetpull/timing/__init__.py +21 -0
- fleetpull/timing/canon.py +92 -0
- fleetpull/timing/clock.py +192 -0
- fleetpull/timing/codec.py +122 -0
- fleetpull/timing/sleeper.py +65 -0
- fleetpull/vocabulary/__init__.py +16 -0
- fleetpull/vocabulary/json_types.py +20 -0
- fleetpull/vocabulary/provider.py +29 -0
- fleetpull/vocabulary/quota_scope.py +47 -0
- fleetpull/vocabulary/response_category.py +32 -0
- fleetpull-0.1.0.dist-info/METADATA +248 -0
- fleetpull-0.1.0.dist-info/RECORD +252 -0
- fleetpull-0.1.0.dist-info/WHEEL +5 -0
- fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
- fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
- fleetpull-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# src/fleetpull/network/client/transport.py
|
|
2
|
+
"""The transport client: HTTP transport, retry, rate limiting, page decoding.
|
|
3
|
+
|
|
4
|
+
Owns one pooled httpx.Client and runs the per-attempt pipeline and the page
|
|
5
|
+
loop. Provider-blind, state-blind, storage-blind, name-blind: every provider
|
|
6
|
+
difference is absorbed by the injected ProviderProfile (auth + classifier) and
|
|
7
|
+
the per-endpoint page decoder. The client runs the decoder over each envelope
|
|
8
|
+
to emit a FetchedPage of records plus an opaque durable_progress cursor; it
|
|
9
|
+
interprets neither the records' field shapes nor the cursor.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import replace
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
from typing import Self
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from fleetpull.exceptions import (
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
ProviderResponseError,
|
|
24
|
+
RetriesExhaustedError,
|
|
25
|
+
)
|
|
26
|
+
from fleetpull.network.client.page import FetchedPage
|
|
27
|
+
from fleetpull.network.client.profile import ProviderProfile
|
|
28
|
+
from fleetpull.network.client.runtime import ClientRuntime
|
|
29
|
+
from fleetpull.network.contract import (
|
|
30
|
+
ClassifiedResponse,
|
|
31
|
+
DecodedPage,
|
|
32
|
+
PageDecoder,
|
|
33
|
+
RequestSpec,
|
|
34
|
+
)
|
|
35
|
+
from fleetpull.network.posture import new_http_client
|
|
36
|
+
from fleetpull.network.retry import RetryDecision, decide_retry
|
|
37
|
+
from fleetpull.vocabulary import JsonValue, ResponseCategory
|
|
38
|
+
|
|
39
|
+
__all__: list[str] = ['TransportClient']
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# The most of a non-JSON body a raised detail may carry: enough to identify
|
|
44
|
+
# a proxy block page or an HTML landing page, never the whole document.
|
|
45
|
+
_BODY_EXCERPT_LIMIT: int = 80
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _safe_body_excerpt(body_text: str) -> str:
|
|
49
|
+
"""Whitespace-collapsed head of a response body, safe for an error detail.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
body_text: The raw response body.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
At most ``_BODY_EXCERPT_LIMIT`` characters, whitespace collapsed.
|
|
56
|
+
"""
|
|
57
|
+
return ' '.join(body_text.split())[:_BODY_EXCERPT_LIMIT]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TransportClient:
|
|
61
|
+
"""
|
|
62
|
+
Pulls one endpoint's pages over HTTP with retry and rate limiting.
|
|
63
|
+
|
|
64
|
+
Owns one pooled ``httpx.Client``, reused across every attempt and (since
|
|
65
|
+
httpx clients are thread-safe) across every concurrent ``fetch_pages``
|
|
66
|
+
call sharing this instance. Reentrant: ``fetch_pages`` keeps its entire
|
|
67
|
+
working set local, so many threads may run it on one client without
|
|
68
|
+
interference. Use as a context manager so the connection pool is closed.
|
|
69
|
+
|
|
70
|
+
Invariants this client must never violate:
|
|
71
|
+
- Every HTTP attempt consumes exactly one limiter token; every page
|
|
72
|
+
is an attempt; ``request_slot()`` wraps exactly one HTTP call,
|
|
73
|
+
never the page loop or the retry loop.
|
|
74
|
+
- ``auth.prepare`` runs OUTSIDE the limiter slot: a GeoTab session
|
|
75
|
+
refresh during prepare consumes the auth scope's token, not the
|
|
76
|
+
data endpoint's, and a failed prepare wastes no data-scope token.
|
|
77
|
+
- The limiter owns all rate-limit waiting. RATE_LIMITED penalizes the
|
|
78
|
+
scope; the next ``request_slot()`` waits it out. The client never
|
|
79
|
+
sleeps for a 429.
|
|
80
|
+
- Per-page consecutive failure counters reset every page by
|
|
81
|
+
construction (they are locals of ``_fetch_single_page``).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, profile: ProviderProfile, runtime: ClientRuntime) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Args:
|
|
87
|
+
profile: The per-provider auth strategy and classifier.
|
|
88
|
+
runtime: Process-global transport infrastructure shared across
|
|
89
|
+
every provider's client.
|
|
90
|
+
|
|
91
|
+
Side Effects:
|
|
92
|
+
Constructs one pooled ``httpx.Client`` held for this client's
|
|
93
|
+
lifetime; close it via the context-manager protocol.
|
|
94
|
+
"""
|
|
95
|
+
self._profile: ProviderProfile = profile
|
|
96
|
+
self._runtime: ClientRuntime = runtime
|
|
97
|
+
self._http_client: httpx.Client = new_http_client(runtime.http_config)
|
|
98
|
+
|
|
99
|
+
def __enter__(self) -> Self:
|
|
100
|
+
"""Return self; the pool is already open."""
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(
|
|
104
|
+
self,
|
|
105
|
+
exc_type: type[BaseException] | None,
|
|
106
|
+
exc_value: BaseException | None,
|
|
107
|
+
traceback: TracebackType | None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Close the connection pool. Exceptions propagate (returns None)."""
|
|
110
|
+
self._http_client.close()
|
|
111
|
+
|
|
112
|
+
def fetch_pages(
|
|
113
|
+
self,
|
|
114
|
+
spec: RequestSpec,
|
|
115
|
+
page_decoder: PageDecoder,
|
|
116
|
+
quota_scope: str,
|
|
117
|
+
) -> Iterator[FetchedPage]:
|
|
118
|
+
"""
|
|
119
|
+
Yield every page of one endpoint, page decoding transparent.
|
|
120
|
+
|
|
121
|
+
One visible loop, terminating when the decoder returns no next spec.
|
|
122
|
+
The decoder builds each next request and extracts each page's records;
|
|
123
|
+
the client only sends the request and emits the records.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
spec: The endpoint definition's credential-less base request.
|
|
127
|
+
page_decoder: The endpoint's page decoder (per-endpoint, NOT
|
|
128
|
+
per-provider).
|
|
129
|
+
quota_scope: The endpoint's rate-limit scope key.
|
|
130
|
+
|
|
131
|
+
Yields:
|
|
132
|
+
One ``FetchedPage`` per page, in order, each carrying its records
|
|
133
|
+
and its ``durable_progress`` (including the terminal page).
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
RetriesExhaustedError: A retryable category exhausted its budget
|
|
137
|
+
on a single page.
|
|
138
|
+
AuthenticationError: Credentials are unfixable, or a second
|
|
139
|
+
consecutive auth failure on one page.
|
|
140
|
+
ProviderResponseError: A FATAL response, a structurally
|
|
141
|
+
violating envelope raised by the page decoder, or a
|
|
142
|
+
SUCCESS-classified response whose body is not JSON.
|
|
143
|
+
"""
|
|
144
|
+
sent: RequestSpec | None = page_decoder.first_request(spec)
|
|
145
|
+
while sent is not None:
|
|
146
|
+
envelope: JsonValue = self._fetch_single_page(sent, quota_scope)
|
|
147
|
+
decoded: DecodedPage = page_decoder.decode_page(sent, envelope)
|
|
148
|
+
yield FetchedPage(
|
|
149
|
+
records=decoded.records,
|
|
150
|
+
durable_progress=decoded.advance.durable_progress,
|
|
151
|
+
)
|
|
152
|
+
sent = decoded.advance.next_spec
|
|
153
|
+
|
|
154
|
+
def fetch_envelope(self, spec: RequestSpec, quota_scope: str) -> JsonValue:
|
|
155
|
+
"""
|
|
156
|
+
Execute one request outside any page loop; return its envelope.
|
|
157
|
+
|
|
158
|
+
The single-request surface for non-paging calls that must still
|
|
159
|
+
ride the whole per-attempt pipeline -- auth prepare, one limiter
|
|
160
|
+
token per attempt, retry, classification (the completeness
|
|
161
|
+
guard's ``GetCountOf`` is the first consumer). Interpretation of
|
|
162
|
+
the envelope is the caller's; the client stays shape-blind.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
spec: The credential-less request to execute.
|
|
166
|
+
quota_scope: The rate-limit scope key the attempt spends from.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The parsed success envelope.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
RetriesExhaustedError: A retryable category exhausted its
|
|
173
|
+
budget.
|
|
174
|
+
AuthenticationError: Credentials are unfixable, or a second
|
|
175
|
+
consecutive auth failure.
|
|
176
|
+
ProviderResponseError: A FATAL response, or a
|
|
177
|
+
SUCCESS-classified response whose body is not JSON.
|
|
178
|
+
"""
|
|
179
|
+
return self._fetch_single_page(spec, quota_scope)
|
|
180
|
+
|
|
181
|
+
def _fetch_single_page(self, sent: RequestSpec, quota_scope: str) -> JsonValue:
|
|
182
|
+
"""
|
|
183
|
+
Drive the retry loop for ONE page; return its success envelope.
|
|
184
|
+
|
|
185
|
+
The three failure counters are locals — they re-zero on entry, which
|
|
186
|
+
IS the reset-on-success semantics: a budget is "N consecutive
|
|
187
|
+
failures of this category on this page", so a long healthy fetch
|
|
188
|
+
never accumulates toward exhaustion and a short fetch is not handed a
|
|
189
|
+
long fetch's budget. The match over ``ResponseCategory`` is
|
|
190
|
+
exhaustive; do not add ``case _``.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
sent: The fully decorated spec for this page.
|
|
194
|
+
quota_scope: The endpoint's rate-limit scope key.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The parsed success envelope for this page.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
RetriesExhaustedError, AuthenticationError, ProviderResponseError
|
|
201
|
+
as documented on ``fetch_pages``.
|
|
202
|
+
"""
|
|
203
|
+
transient_failures: int = 0
|
|
204
|
+
rate_limited_failures: int = 0
|
|
205
|
+
auth_failures: int = 0
|
|
206
|
+
while True:
|
|
207
|
+
classified: ClassifiedResponse = self._attempt(sent, quota_scope)
|
|
208
|
+
match classified.category:
|
|
209
|
+
case ResponseCategory.SUCCESS:
|
|
210
|
+
# _attempt guarantees parsed_body is the envelope on
|
|
211
|
+
# SUCCESS; the guard narrows JsonValue | None and fires
|
|
212
|
+
# only on a contract violation.
|
|
213
|
+
envelope: JsonValue | None = classified.parsed_body
|
|
214
|
+
if envelope is None:
|
|
215
|
+
raise ProviderResponseError(
|
|
216
|
+
detail='classifier reported SUCCESS with no parsed body'
|
|
217
|
+
)
|
|
218
|
+
return envelope
|
|
219
|
+
case ResponseCategory.TRANSIENT:
|
|
220
|
+
transient_failures += 1
|
|
221
|
+
transient_decision: RetryDecision = self._retry_or_exhaust(
|
|
222
|
+
ResponseCategory.TRANSIENT, transient_failures
|
|
223
|
+
)
|
|
224
|
+
self._runtime.sleeper.sleep(transient_decision.local_delay_seconds)
|
|
225
|
+
case ResponseCategory.RATE_LIMITED:
|
|
226
|
+
rate_limited_failures += 1
|
|
227
|
+
self._retry_or_exhaust(
|
|
228
|
+
ResponseCategory.RATE_LIMITED, rate_limited_failures
|
|
229
|
+
)
|
|
230
|
+
self._penalize_scope(quota_scope, classified)
|
|
231
|
+
case ResponseCategory.AUTH_FAILURE:
|
|
232
|
+
auth_failures += 1
|
|
233
|
+
# Sessions get exactly one refresh-and-retry per page; a
|
|
234
|
+
# static key (on_auth_failure False) or a second failure
|
|
235
|
+
# in a row is unfixable.
|
|
236
|
+
if auth_failures > 1 or not self._profile.auth.on_auth_failure():
|
|
237
|
+
raise AuthenticationError(detail=classified.detail)
|
|
238
|
+
case ResponseCategory.FATAL:
|
|
239
|
+
raise ProviderResponseError(detail=classified.detail)
|
|
240
|
+
|
|
241
|
+
def _retry_or_exhaust(
|
|
242
|
+
self, category: ResponseCategory, failure_count: int
|
|
243
|
+
) -> RetryDecision:
|
|
244
|
+
"""
|
|
245
|
+
Decide one more retry for a retryable category, or raise.
|
|
246
|
+
|
|
247
|
+
Raising on exhaustion happens BEFORE the caller's category-specific
|
|
248
|
+
action (sleep or penalize), so the exhausted attempt pays no delay
|
|
249
|
+
and applies no penalty — pinned by the transport tests.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
category: The retryable category being budgeted.
|
|
253
|
+
failure_count: Consecutive failures of this category on this
|
|
254
|
+
page, including the one just observed.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
The retry decision; the caller applies its category-specific
|
|
258
|
+
action.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
RetriesExhaustedError: The category's failure budget is spent.
|
|
262
|
+
"""
|
|
263
|
+
decision: RetryDecision = decide_retry(
|
|
264
|
+
category,
|
|
265
|
+
failure_count,
|
|
266
|
+
self._runtime.retry_config,
|
|
267
|
+
self._runtime.random_source,
|
|
268
|
+
)
|
|
269
|
+
if not decision.should_retry:
|
|
270
|
+
raise RetriesExhaustedError(
|
|
271
|
+
category=category,
|
|
272
|
+
attempt_count=failure_count,
|
|
273
|
+
)
|
|
274
|
+
return decision
|
|
275
|
+
|
|
276
|
+
def _attempt(self, sent: RequestSpec, quota_scope: str) -> ClassifiedResponse:
|
|
277
|
+
"""
|
|
278
|
+
Run one attempt: prepare (outside the slot), then send (inside it).
|
|
279
|
+
|
|
280
|
+
Returns one classification. prepare-time and send-time transport
|
|
281
|
+
failures both route through ``classify_transport_exception`` (always
|
|
282
|
+
TRANSIENT). Non-transport errors from the authenticator
|
|
283
|
+
(AuthenticationError, ProviderResponseError) are not caught and
|
|
284
|
+
propagate untouched. On SUCCESS the parsed envelope is guaranteed on
|
|
285
|
+
``parsed_body``: a status-only classifier (Motive/Samsara) leaves it
|
|
286
|
+
None, so the body is parsed here; a classifier that already parsed
|
|
287
|
+
(GeoTab) is left untouched, so the body is parsed at most once.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
sent: The fully decorated spec for this attempt.
|
|
291
|
+
quota_scope: The endpoint's rate-limit scope key.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
The classification of this attempt's response or transport failure.
|
|
295
|
+
|
|
296
|
+
Side Effects:
|
|
297
|
+
Consumes one limiter token for the data request; may drive a
|
|
298
|
+
network Authenticate inside ``prepare`` (GeoTab), which consumes
|
|
299
|
+
the auth scope's own token via the authenticator.
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
prepared: RequestSpec = self._profile.auth.prepare(sent)
|
|
303
|
+
except httpx.TransportError as prepare_transport_error:
|
|
304
|
+
return self._profile.classifier.classify_transport_exception(
|
|
305
|
+
prepare_transport_error
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
with self._runtime.limiter_registry.get(quota_scope).request_slot():
|
|
309
|
+
try:
|
|
310
|
+
response: httpx.Response = self._http_client.request(
|
|
311
|
+
method=prepared.method,
|
|
312
|
+
url=prepared.url,
|
|
313
|
+
headers=dict(prepared.headers),
|
|
314
|
+
params=dict(prepared.params)
|
|
315
|
+
if prepared.params is not None
|
|
316
|
+
else None,
|
|
317
|
+
json=prepared.json_body,
|
|
318
|
+
)
|
|
319
|
+
except httpx.TransportError as send_transport_error:
|
|
320
|
+
return self._profile.classifier.classify_transport_exception(
|
|
321
|
+
send_transport_error
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Classification runs after the slot releases — it is CPU, not a
|
|
325
|
+
# request; the slot wrapped exactly the HTTP call.
|
|
326
|
+
body_text: str = response.text
|
|
327
|
+
classified: ClassifiedResponse = self._profile.classifier.classify_response(
|
|
328
|
+
response.status_code, response.headers, body_text
|
|
329
|
+
)
|
|
330
|
+
if (
|
|
331
|
+
classified.category is ResponseCategory.SUCCESS
|
|
332
|
+
and classified.parsed_body is None
|
|
333
|
+
):
|
|
334
|
+
try:
|
|
335
|
+
parsed_envelope: JsonValue = json.loads(body_text)
|
|
336
|
+
except json.JSONDecodeError:
|
|
337
|
+
# A 200 serving non-JSON (a TLS-intercepting proxy's block
|
|
338
|
+
# page, a base_url pointing at a web page) is sustained, not
|
|
339
|
+
# transient: name it rather than burn the retry budget. The
|
|
340
|
+
# detail carries a sanitized excerpt only -- a block page may
|
|
341
|
+
# contain environment details that must never reach logs.
|
|
342
|
+
raise ProviderResponseError(
|
|
343
|
+
detail=(
|
|
344
|
+
f'classifier reported SUCCESS but the body is not '
|
|
345
|
+
f'JSON (content-type '
|
|
346
|
+
f'{response.headers.get("content-type", "")!r}): '
|
|
347
|
+
f'{_safe_body_excerpt(body_text)!r}'
|
|
348
|
+
)
|
|
349
|
+
) from None
|
|
350
|
+
classified = replace(classified, parsed_body=parsed_envelope)
|
|
351
|
+
return classified
|
|
352
|
+
|
|
353
|
+
def _penalize_scope(self, quota_scope: str, classified: ClassifiedResponse) -> None:
|
|
354
|
+
"""
|
|
355
|
+
Penalize the whole quota scope for a 429, clamping to the fallback.
|
|
356
|
+
|
|
357
|
+
``penalize`` rejects non-positive seconds, so a missing or
|
|
358
|
+
non-positive parsed Retry-After clamps to ``fallback_penalty_seconds``
|
|
359
|
+
and the parsed value is logged for diagnosis.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
quota_scope: The endpoint's rate-limit scope key.
|
|
363
|
+
classified: The RATE_LIMITED verdict carrying the parsed
|
|
364
|
+
Retry-After, when the provider sent a usable one.
|
|
365
|
+
|
|
366
|
+
Side Effects:
|
|
367
|
+
Extends the scope-wide pause; logs a WARNING when no usable
|
|
368
|
+
Retry-After was present.
|
|
369
|
+
"""
|
|
370
|
+
retry_after_seconds: float | None = classified.retry_after_seconds
|
|
371
|
+
if retry_after_seconds is not None and retry_after_seconds > 0:
|
|
372
|
+
penalty_seconds: float = retry_after_seconds
|
|
373
|
+
else:
|
|
374
|
+
logger.warning(
|
|
375
|
+
'Rate-limited with no usable Retry-After (parsed=%r); applying '
|
|
376
|
+
'fallback penalty of %.2f seconds.',
|
|
377
|
+
retry_after_seconds,
|
|
378
|
+
self._runtime.retry_config.fallback_penalty_seconds,
|
|
379
|
+
)
|
|
380
|
+
penalty_seconds = self._runtime.retry_config.fallback_penalty_seconds
|
|
381
|
+
self._runtime.limiter_registry.get(quota_scope).penalize(penalty_seconds)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# src/fleetpull/network/contract/__init__.py
|
|
2
|
+
"""The request/response contract: the shared vocabulary and protocols
|
|
3
|
+
every network layer speaks. Provider implementations (classifiers,
|
|
4
|
+
page decoders, auth strategies) live in sibling packages and import
|
|
5
|
+
this surface through this face."""
|
|
6
|
+
|
|
7
|
+
from fleetpull.network.contract.auth import AuthStrategy
|
|
8
|
+
from fleetpull.network.contract.classifier import (
|
|
9
|
+
SERVER_ERROR_FLOOR,
|
|
10
|
+
SUCCESS_STATUS_RANGE,
|
|
11
|
+
ResponseClassifier,
|
|
12
|
+
body_snippet,
|
|
13
|
+
retry_after_seconds_from_headers,
|
|
14
|
+
)
|
|
15
|
+
from fleetpull.network.contract.envelope_fetcher import EnvelopeFetcher
|
|
16
|
+
from fleetpull.network.contract.envelopes import (
|
|
17
|
+
StrictEnvelopeSlice,
|
|
18
|
+
require_child_object,
|
|
19
|
+
require_record_list,
|
|
20
|
+
unwrap_record_objects,
|
|
21
|
+
validated_envelope_slice,
|
|
22
|
+
)
|
|
23
|
+
from fleetpull.network.contract.outcome import ClassifiedResponse
|
|
24
|
+
from fleetpull.network.contract.page_decoder import (
|
|
25
|
+
DecodedPage,
|
|
26
|
+
PageAdvance,
|
|
27
|
+
PageDecoder,
|
|
28
|
+
)
|
|
29
|
+
from fleetpull.network.contract.request import HttpMethod, RequestSpec
|
|
30
|
+
|
|
31
|
+
__all__: list[str] = [
|
|
32
|
+
'SERVER_ERROR_FLOOR',
|
|
33
|
+
'SUCCESS_STATUS_RANGE',
|
|
34
|
+
'AuthStrategy',
|
|
35
|
+
'ClassifiedResponse',
|
|
36
|
+
'DecodedPage',
|
|
37
|
+
'EnvelopeFetcher',
|
|
38
|
+
'HttpMethod',
|
|
39
|
+
'PageAdvance',
|
|
40
|
+
'PageDecoder',
|
|
41
|
+
'RequestSpec',
|
|
42
|
+
'ResponseClassifier',
|
|
43
|
+
'StrictEnvelopeSlice',
|
|
44
|
+
'body_snippet',
|
|
45
|
+
'require_child_object',
|
|
46
|
+
'require_record_list',
|
|
47
|
+
'retry_after_seconds_from_headers',
|
|
48
|
+
'unwrap_record_objects',
|
|
49
|
+
'validated_envelope_slice',
|
|
50
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# src/fleetpull/network/contract/auth.py
|
|
2
|
+
"""The auth-strategy protocol: pure shape, the contract every strategy implements.
|
|
3
|
+
|
|
4
|
+
Implementations live in ``network/auth/strategies.py`` (beside the
|
|
5
|
+
``GeotabSessionManager`` that ``GeotabSessionAuth`` wraps); keeping them
|
|
6
|
+
out of this surface module is what lets the contract face stay free of
|
|
7
|
+
any ``network/auth`` dependency.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from fleetpull.network.contract.request import RequestSpec
|
|
13
|
+
|
|
14
|
+
__all__: list[str] = ['AuthStrategy']
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthStrategy(Protocol):
|
|
18
|
+
"""Provider-agnostic credential injection (DESIGN.md §8)."""
|
|
19
|
+
|
|
20
|
+
def prepare(self, spec: RequestSpec) -> RequestSpec:
|
|
21
|
+
"""
|
|
22
|
+
Inject credentials and return a new spec.
|
|
23
|
+
|
|
24
|
+
Called fresh for EVERY HTTP attempt — every page, every retry
|
|
25
|
+
(the prepare-per-attempt contract, symmetric with
|
|
26
|
+
token-per-attempt). After a successful ``on_auth_failure``, the
|
|
27
|
+
retry must carry the fresh credentials, so re-preparing is
|
|
28
|
+
mandatory; implementations must keep the hot path cheap.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
spec: The credential-less request description.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A new spec carrying credentials; the input is unchanged.
|
|
35
|
+
"""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def on_auth_failure(self) -> bool:
|
|
39
|
+
"""
|
|
40
|
+
Answer "did I change anything that makes one retry worthwhile?"
|
|
41
|
+
|
|
42
|
+
Performs the fix (if any) as a side effect.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
True when fresh credentials are now available and one retry
|
|
46
|
+
is worthwhile; False when nothing can be fixed by retrying.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# src/fleetpull/network/contract/classifier.py
|
|
2
|
+
"""ResponseClassifier ABC and the shared helpers every classifier uses.
|
|
3
|
+
|
|
4
|
+
This module is the only one in the contract package allowed to import
|
|
5
|
+
httpx: the shared transport-exception mapping must name real exception
|
|
6
|
+
types, and the classifier is the transport boundary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from fleetpull.network.contract.outcome import ClassifiedResponse
|
|
17
|
+
from fleetpull.vocabulary import ResponseCategory
|
|
18
|
+
|
|
19
|
+
__all__: list[str] = [
|
|
20
|
+
'SERVER_ERROR_FLOOR',
|
|
21
|
+
'SUCCESS_STATUS_RANGE',
|
|
22
|
+
'ResponseClassifier',
|
|
23
|
+
'body_snippet',
|
|
24
|
+
'retry_after_seconds_from_headers',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Cap on body text carried into ClassifiedResponse.detail. Every
|
|
28
|
+
# classifier truncates through body_snippet; no inline magic numbers.
|
|
29
|
+
_BODY_SNIPPET_MAX_CHARS: Final[int] = 200
|
|
30
|
+
|
|
31
|
+
# Generic HTTP status boundaries shared by all classifiers — protocol
|
|
32
|
+
# semantics, not provider behavior, so they live in the base module.
|
|
33
|
+
# These stay as a range and a bare int deliberately: the tempting
|
|
34
|
+
# alternative, HTTPStatus(code).is_success / .is_server_error,
|
|
35
|
+
# constructs the enum from an arbitrary code and raises ValueError on
|
|
36
|
+
# nonstandard statuses (e.g. Cloudflare's 522). A classifier must
|
|
37
|
+
# classify every status it meets, never crash on one. HTTPStatus
|
|
38
|
+
# members are reserved for equality against specific well-known codes
|
|
39
|
+
# in the provider classifiers; band membership is checked against
|
|
40
|
+
# these constants.
|
|
41
|
+
SUCCESS_STATUS_RANGE: Final[range] = range(200, 300)
|
|
42
|
+
SERVER_ERROR_FLOOR: Final[int] = 500
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_header(headers: Mapping[str, str], name: str) -> str | None:
|
|
46
|
+
"""
|
|
47
|
+
Case-insensitive header lookup.
|
|
48
|
+
|
|
49
|
+
HTTP headers are case-insensitive (captures show ``retry-after``
|
|
50
|
+
lowercase from GeoTab), and this contract must not depend on
|
|
51
|
+
httpx's case-insensitive mapping being the one passed in.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
headers: The response headers.
|
|
55
|
+
name: Header name, any casing.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The header value, or None when absent.
|
|
59
|
+
"""
|
|
60
|
+
normalized_name: str = name.lower()
|
|
61
|
+
for header_name, header_value in headers.items():
|
|
62
|
+
if header_name.lower() == normalized_name:
|
|
63
|
+
return header_value
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_retry_after_seconds(value: str) -> float | None:
|
|
68
|
+
"""
|
|
69
|
+
Parse the numeric-seconds form of a Retry-After value.
|
|
70
|
+
|
|
71
|
+
Samsara sends fractional seconds (e.g. ``0.40235``); GeoTab sends
|
|
72
|
+
integers (e.g. ``58``). The HTTP-date form is deliberately unparsed
|
|
73
|
+
in v1. The consumer is the limiter's ``penalize(seconds)``, which
|
|
74
|
+
raises on ``seconds <= 0`` — this helper must never hand it an
|
|
75
|
+
invalid value, so anything that is not a finite positive number
|
|
76
|
+
maps to None, which safely triggers the client's fallback penalty.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
value: The raw header value.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The positive, finite seconds value, or None for everything
|
|
83
|
+
else (non-numeric strings, HTTP dates, zero, negatives, NaN,
|
|
84
|
+
infinity).
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
seconds: float = float(value)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return None
|
|
90
|
+
if not math.isfinite(seconds) or seconds <= 0:
|
|
91
|
+
return None
|
|
92
|
+
return seconds
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def retry_after_seconds_from_headers(headers: Mapping[str, str]) -> float | None:
|
|
96
|
+
"""
|
|
97
|
+
Extract and parse the ``Retry-After`` header's numeric-seconds form.
|
|
98
|
+
|
|
99
|
+
Composes the module's header-lookup and seconds-parsing helpers —
|
|
100
|
+
the composition is HTTP protocol semantics shared by every provider,
|
|
101
|
+
not provider behavior, so it lives in the base module.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
headers: The response headers.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The positive, finite seconds value, or None when the header is
|
|
108
|
+
absent or unusable (the client then applies its fallback penalty).
|
|
109
|
+
"""
|
|
110
|
+
retry_after_value: str | None = _find_header(headers, 'Retry-After')
|
|
111
|
+
if retry_after_value is None:
|
|
112
|
+
return None
|
|
113
|
+
return _parse_retry_after_seconds(retry_after_value)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def body_snippet(body_text: str) -> str:
|
|
117
|
+
"""
|
|
118
|
+
Truncate body text for use in ``ClassifiedResponse.detail``.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
body_text: The raw response body.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The text unchanged when within the cap; otherwise the first
|
|
125
|
+
``_BODY_SNIPPET_MAX_CHARS`` characters with a ``…`` marker.
|
|
126
|
+
"""
|
|
127
|
+
if len(body_text) <= _BODY_SNIPPET_MAX_CHARS:
|
|
128
|
+
return body_text
|
|
129
|
+
return body_text[:_BODY_SNIPPET_MAX_CHARS] + '…'
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ResponseClassifier(ABC):
|
|
133
|
+
"""
|
|
134
|
+
Per-provider response classifier — the SOLE producer of the
|
|
135
|
+
classification vocabulary; the client only consumes it.
|
|
136
|
+
|
|
137
|
+
``classify_response`` is abstract because provider envelopes differ
|
|
138
|
+
(GeoTab returns JSON-RPC errors inside HTTP 200, so a
|
|
139
|
+
status-code-only client cannot see failure). The transport-exception
|
|
140
|
+
mapping is concrete in the base because timeouts and connection
|
|
141
|
+
failures sit below any provider envelope and must not vary per
|
|
142
|
+
provider (Protocol-for-shape / ABC-for-substance, DESIGN.md §8).
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
@abstractmethod
|
|
146
|
+
def classify_response(
|
|
147
|
+
self,
|
|
148
|
+
status_code: int,
|
|
149
|
+
headers: Mapping[str, str],
|
|
150
|
+
body_text: str,
|
|
151
|
+
) -> ClassifiedResponse:
|
|
152
|
+
"""
|
|
153
|
+
Classify one provider response.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
status_code: The HTTP status code.
|
|
157
|
+
headers: The response headers.
|
|
158
|
+
body_text: The raw response body.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The classified outcome the client dispatches on.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def classify_transport_exception(self, exception: Exception) -> ClassifiedResponse:
|
|
165
|
+
"""
|
|
166
|
+
Classify an exception raised by the transport, shared across
|
|
167
|
+
all providers.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
exception: The exception the HTTP attempt raised.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
TRANSIENT for any ``httpx.TransportError`` (the hierarchy
|
|
174
|
+
makes timeouts and connect/read/write failures subclasses,
|
|
175
|
+
so one check covers them all), with the concrete exception
|
|
176
|
+
class name in ``detail``.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
Exception: Any non-transport exception is re-raised
|
|
180
|
+
untouched — a ValueError or KeyError reaching the
|
|
181
|
+
classifier is a programming error, and classifying it
|
|
182
|
+
would silence a bug.
|
|
183
|
+
"""
|
|
184
|
+
if isinstance(exception, httpx.TransportError):
|
|
185
|
+
return ClassifiedResponse(
|
|
186
|
+
category=ResponseCategory.TRANSIENT,
|
|
187
|
+
detail=f'transport error: {type(exception).__name__}',
|
|
188
|
+
)
|
|
189
|
+
raise exception
|