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,223 @@
|
|
|
1
|
+
# src/fleetpull/network/auth/manager.py
|
|
2
|
+
"""GeoTab session lifecycle manager.
|
|
3
|
+
|
|
4
|
+
GeoTab authenticates by session: an ``Authenticate`` call returns a
|
|
5
|
+
session id and a resolved host; the session lives ~14 days but can die
|
|
6
|
+
early (password change; a 100-concurrent-session LRU cap per account).
|
|
7
|
+
This module is pure state and thread choreography — it knows nothing
|
|
8
|
+
about HTTP. The actual ``Authenticate`` call is injected as
|
|
9
|
+
``authenticate_fn``; in production that implementation is an HTTP
|
|
10
|
+
attempt and passes through the rate limiter like any other
|
|
11
|
+
(token-per-attempt has no exceptions).
|
|
12
|
+
|
|
13
|
+
Sessions are never persisted to disk — a deliberate non-goal. A session
|
|
14
|
+
id is a bearer-equivalent secret, and at one process per scheduled run,
|
|
15
|
+
in-memory sessions stay far below GeoTab's 100-session cap.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import threading
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from datetime import datetime, timedelta
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
from fleetpull.config import GeotabAuthConfig
|
|
25
|
+
from fleetpull.network.auth.models import AuthenticationResult, GeotabSession
|
|
26
|
+
from fleetpull.timing import Clock, SystemClock
|
|
27
|
+
|
|
28
|
+
__all__: list[str] = ['GeotabSessionManager']
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Documented GeoTab policy, not a server-returned contract — there is no
|
|
33
|
+
# expires_in in the Authenticate response, and the docs reserve the
|
|
34
|
+
# right to change the lifetime.
|
|
35
|
+
_SESSION_LIFETIME: Final[timedelta] = timedelta(days=14)
|
|
36
|
+
|
|
37
|
+
# Proactive refresh threshold. Generous (a day, not seconds) because the
|
|
38
|
+
# lifetime is assumed, not negotiated: refreshing early costs one
|
|
39
|
+
# Authenticate call; trusting an assumed lifetime to the minute risks a
|
|
40
|
+
# mid-run failure.
|
|
41
|
+
_REFRESH_MARGIN: Final[timedelta] = timedelta(days=1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GeotabSessionManager:
|
|
45
|
+
"""
|
|
46
|
+
Single-flight GeoTab session cache: one session per process, shared
|
|
47
|
+
by all threads.
|
|
48
|
+
|
|
49
|
+
Reactive invalidation (a caller reports a server-rejected session
|
|
50
|
+
via :meth:`invalidate`) is the primary mechanism; proactive refresh
|
|
51
|
+
near the assumed lifetime is insurance. A generation counter guards
|
|
52
|
+
against invalidation stampedes: ten workers hitting expiry
|
|
53
|
+
simultaneously produce one ``Authenticate`` call, not ten.
|
|
54
|
+
|
|
55
|
+
The lock is deliberately held across the ``authenticate_fn`` call.
|
|
56
|
+
That is the single-flight design: concurrent callers needing a
|
|
57
|
+
refresh block on the lock and receive the just-refreshed session
|
|
58
|
+
when they acquire it. This is the opposite of the SQLite
|
|
59
|
+
never-hold-a-transaction-across-HTTP rule, and the difference is
|
|
60
|
+
the point — do not "fix" it.
|
|
61
|
+
|
|
62
|
+
This class never logs ``session_id`` and never touches
|
|
63
|
+
``config.password``; only ``authenticate_fn`` reads the secret.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
config: GeotabAuthConfig,
|
|
69
|
+
authenticate_fn: Callable[[GeotabAuthConfig], AuthenticationResult],
|
|
70
|
+
clock: Clock = SystemClock(), # noqa: B008 — SystemClock is frozen and stateless; one shared default instance is intentional
|
|
71
|
+
) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Initialize the manager with no cached session.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
config: Validated GeoTab authentication configuration.
|
|
77
|
+
authenticate_fn: Performs the actual ``Authenticate`` call
|
|
78
|
+
and resolves the returned host. Injected so the manager
|
|
79
|
+
stays pure state and choreography (the real
|
|
80
|
+
implementation in ``network/auth/authenticate.py`` is the
|
|
81
|
+
one place httpx is imported); in tests it is a stub.
|
|
82
|
+
clock: Time source; injected so tests can be deterministic.
|
|
83
|
+
"""
|
|
84
|
+
self._config: GeotabAuthConfig = config
|
|
85
|
+
self._authenticate_fn: Callable[[GeotabAuthConfig], AuthenticationResult] = (
|
|
86
|
+
authenticate_fn
|
|
87
|
+
)
|
|
88
|
+
self._clock: Clock = clock
|
|
89
|
+
self._lock: threading.Lock = threading.Lock()
|
|
90
|
+
self._current_session: GeotabSession | None = None
|
|
91
|
+
self._generation_counter: int = 0
|
|
92
|
+
|
|
93
|
+
def get_session(self) -> GeotabSession:
|
|
94
|
+
"""
|
|
95
|
+
Return a usable session, authenticating or refreshing as needed.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The cached session, or a freshly authenticated one when no
|
|
99
|
+
session exists yet or the cached session has crossed the
|
|
100
|
+
proactive refresh threshold.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
AuthenticationError: Bad credentials (from the real
|
|
104
|
+
``authenticate_fn``).
|
|
105
|
+
ProviderResponseError: A malformed or non-200 Authenticate
|
|
106
|
+
response.
|
|
107
|
+
UnknownQuotaScopeError: The Authenticate quota scope is
|
|
108
|
+
unconfigured.
|
|
109
|
+
httpx.TransportError: A transport failure during
|
|
110
|
+
authentication. Cached state is untouched on any raise.
|
|
111
|
+
|
|
112
|
+
Side Effects:
|
|
113
|
+
May call ``authenticate_fn`` (blocking other threads on the
|
|
114
|
+
internal lock for the duration) and replace the cached
|
|
115
|
+
session.
|
|
116
|
+
"""
|
|
117
|
+
with self._lock:
|
|
118
|
+
if self._current_session is None:
|
|
119
|
+
logger.debug('No GeoTab session cached; initial authentication.')
|
|
120
|
+
return self._refresh_holding_lock()
|
|
121
|
+
|
|
122
|
+
now_utc: datetime = self._clock.now_utc()
|
|
123
|
+
refresh_due_at: datetime = self._current_session.acquired_at_utc + (
|
|
124
|
+
_SESSION_LIFETIME - _REFRESH_MARGIN
|
|
125
|
+
)
|
|
126
|
+
# Inclusive >= so the boundary instant refreshes rather than
|
|
127
|
+
# living in an untestable gap.
|
|
128
|
+
if now_utc >= refresh_due_at:
|
|
129
|
+
session_age: timedelta = now_utc - self._current_session.acquired_at_utc
|
|
130
|
+
logger.debug(
|
|
131
|
+
'GeoTab session past proactive refresh threshold '
|
|
132
|
+
'(age %s); refreshing.',
|
|
133
|
+
session_age,
|
|
134
|
+
)
|
|
135
|
+
return self._refresh_holding_lock()
|
|
136
|
+
|
|
137
|
+
logger.debug(
|
|
138
|
+
'GeoTab session cache hit (generation %d).',
|
|
139
|
+
self._current_session.generation,
|
|
140
|
+
)
|
|
141
|
+
return self._current_session
|
|
142
|
+
|
|
143
|
+
def invalidate(self, stale_session: GeotabSession) -> GeotabSession:
|
|
144
|
+
"""
|
|
145
|
+
Report a server-rejected session and receive a replacement.
|
|
146
|
+
|
|
147
|
+
Called by a caller whose request failed with a session-invalid
|
|
148
|
+
error. If another thread already refreshed since the caller read
|
|
149
|
+
its session (the reported generation is older than the current
|
|
150
|
+
one), the current session is returned without authenticating —
|
|
151
|
+
the single-flight stampede guard.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
stale_session: The session the server rejected.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The current session: freshly authenticated, or the newer
|
|
158
|
+
one another thread already obtained.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
AuthenticationError: Bad credentials (from the real
|
|
162
|
+
``authenticate_fn``).
|
|
163
|
+
ProviderResponseError: A malformed or non-200 Authenticate
|
|
164
|
+
response.
|
|
165
|
+
UnknownQuotaScopeError: The Authenticate quota scope is
|
|
166
|
+
unconfigured.
|
|
167
|
+
httpx.TransportError: A transport failure during
|
|
168
|
+
authentication. Cached state is untouched on any raise.
|
|
169
|
+
|
|
170
|
+
Side Effects:
|
|
171
|
+
May call ``authenticate_fn`` and replace the cached
|
|
172
|
+
session. Logs a WARNING when the current session was
|
|
173
|
+
genuinely rejected (it died before its assumed lifetime).
|
|
174
|
+
"""
|
|
175
|
+
with self._lock:
|
|
176
|
+
current_session: GeotabSession | None = self._current_session
|
|
177
|
+
if (
|
|
178
|
+
current_session is not None
|
|
179
|
+
and stale_session.generation < current_session.generation
|
|
180
|
+
):
|
|
181
|
+
logger.debug(
|
|
182
|
+
'Stale invalidation for generation %d; current '
|
|
183
|
+
'generation %d is already newer.',
|
|
184
|
+
stale_session.generation,
|
|
185
|
+
current_session.generation,
|
|
186
|
+
)
|
|
187
|
+
return current_session
|
|
188
|
+
|
|
189
|
+
# The reported session IS current and the server rejected it:
|
|
190
|
+
# it died before its assumed lifetime — password change or
|
|
191
|
+
# LRU eviction, something that happened outside this process.
|
|
192
|
+
logger.warning(
|
|
193
|
+
'GeoTab session (generation %d) rejected by the server '
|
|
194
|
+
'before its assumed lifetime; re-authenticating.',
|
|
195
|
+
stale_session.generation,
|
|
196
|
+
)
|
|
197
|
+
return self._refresh_holding_lock()
|
|
198
|
+
|
|
199
|
+
def _refresh_holding_lock(self) -> GeotabSession:
|
|
200
|
+
"""
|
|
201
|
+
Authenticate and replace the cached session. Caller holds the lock.
|
|
202
|
+
|
|
203
|
+
``issued_at`` is read BEFORE calling ``authenticate_fn`` —
|
|
204
|
+
pessimistic timestamping, so network latency counts against the
|
|
205
|
+
session lifetime rather than silently extending it. If
|
|
206
|
+
``authenticate_fn`` raises, cached state is untouched and the
|
|
207
|
+
exception propagates.
|
|
208
|
+
"""
|
|
209
|
+
issued_at: datetime = self._clock.now_utc()
|
|
210
|
+
authentication_result: AuthenticationResult = self._authenticate_fn(
|
|
211
|
+
self._config
|
|
212
|
+
)
|
|
213
|
+
self._generation_counter += 1
|
|
214
|
+
new_session = GeotabSession(
|
|
215
|
+
session_id=authentication_result.session_id,
|
|
216
|
+
resolved_host=authentication_result.resolved_host,
|
|
217
|
+
database=self._config.database,
|
|
218
|
+
username=self._config.username,
|
|
219
|
+
generation=self._generation_counter,
|
|
220
|
+
acquired_at_utc=issued_at,
|
|
221
|
+
)
|
|
222
|
+
self._current_session = new_session
|
|
223
|
+
return new_session
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# src/fleetpull/network/auth/models.py
|
|
2
|
+
"""Runtime session state for GeoTab authentication.
|
|
3
|
+
|
|
4
|
+
Internal runtime state, not user YAML — hence frozen dataclasses rather
|
|
5
|
+
than Pydantic models. Pure data: assembling request credentials from a
|
|
6
|
+
session is the auth strategy's job, so nothing here knows JSON-RPC
|
|
7
|
+
payload shapes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
__all__: list[str] = ['AuthenticationResult', 'GeotabSession']
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class AuthenticationResult:
|
|
18
|
+
"""
|
|
19
|
+
What an ``authenticate_fn`` returns to the session manager.
|
|
20
|
+
|
|
21
|
+
GeoTab's ``Authenticate`` returns either a server path or the
|
|
22
|
+
literal ``'ThisServer'``; RESOLVING that to a concrete host is the
|
|
23
|
+
``authenticate_fn``'s job (it knows which host it called), so by the
|
|
24
|
+
time a result reaches the manager, ``resolved_host`` is always a
|
|
25
|
+
real host.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
session_id: The session credential returned by ``Authenticate``.
|
|
29
|
+
resolved_host: The host all subsequent API calls must target.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
session_id: str
|
|
33
|
+
resolved_host: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class GeotabSession:
|
|
38
|
+
"""
|
|
39
|
+
The session the manager hands to callers.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
session_id: The session credential for request payloads.
|
|
43
|
+
resolved_host: The host all subsequent API calls must target.
|
|
44
|
+
database: GeoTab database name, carried from config.
|
|
45
|
+
username: GeoTab username, carried from config.
|
|
46
|
+
generation: Monotonically increasing per manager instance; the
|
|
47
|
+
single-flight staleness check compares generations.
|
|
48
|
+
acquired_at_utc: When authentication was initiated, from the
|
|
49
|
+
injected clock's ``now_utc()``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
session_id: str
|
|
53
|
+
resolved_host: str
|
|
54
|
+
database: str
|
|
55
|
+
username: str
|
|
56
|
+
generation: int
|
|
57
|
+
acquired_at_utc: datetime
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# src/fleetpull/network/auth/strategies.py
|
|
2
|
+
"""The ``AuthStrategy`` implementations, beside the session manager.
|
|
3
|
+
|
|
4
|
+
``StaticHeaderAuth`` (Motive/Samsara static keys) and
|
|
5
|
+
``GeotabSessionAuth`` (GeoTab sessions) implement the ``AuthStrategy``
|
|
6
|
+
protocol declared in ``network/contract/auth.py``. They live here, in
|
|
7
|
+
``network/auth/``, because ``GeotabSessionAuth`` wraps the
|
|
8
|
+
``GeotabSessionManager`` it sits beside — keeping the implementations
|
|
9
|
+
out of the contract surface is what lets the surface stay free of any
|
|
10
|
+
``network/auth`` dependency.
|
|
11
|
+
|
|
12
|
+
Provider names appear only at the composition root that constructs
|
|
13
|
+
strategies; the client and everything downstream is provider-blind.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from dataclasses import replace
|
|
19
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
20
|
+
|
|
21
|
+
from pydantic import SecretStr
|
|
22
|
+
|
|
23
|
+
from fleetpull.network.auth.manager import GeotabSessionManager
|
|
24
|
+
from fleetpull.network.auth.models import GeotabSession
|
|
25
|
+
from fleetpull.network.contract import RequestSpec
|
|
26
|
+
from fleetpull.vocabulary import JsonValue
|
|
27
|
+
|
|
28
|
+
__all__: list[str] = ['GeotabSessionAuth', 'StaticHeaderAuth']
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StaticHeaderAuth:
|
|
32
|
+
"""
|
|
33
|
+
Static API-key auth for Motive and Samsara.
|
|
34
|
+
|
|
35
|
+
The composition root pre-formats the header value (e.g.
|
|
36
|
+
``'Bearer <token>'`` for Samsara) before wrapping it in
|
|
37
|
+
``SecretStr``; the secret is extracted only at the moment of use.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, header_name: str, header_value: SecretStr) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Args:
|
|
43
|
+
header_name: The header carrying the credential.
|
|
44
|
+
header_value: The pre-formatted credential value.
|
|
45
|
+
"""
|
|
46
|
+
self._header_name: str = header_name
|
|
47
|
+
self._header_value: SecretStr = header_value
|
|
48
|
+
|
|
49
|
+
def prepare(self, spec: RequestSpec) -> RequestSpec:
|
|
50
|
+
"""Return a new spec with the credential header injected."""
|
|
51
|
+
return spec.with_extra_headers(
|
|
52
|
+
{self._header_name: self._header_value.get_secret_value()}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def on_auth_failure(self) -> bool:
|
|
56
|
+
"""A rejected static key cannot be fixed by retrying."""
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _ThreadLocalSession(threading.local):
|
|
61
|
+
"""Per-thread slot for the last session injected by prepare()."""
|
|
62
|
+
|
|
63
|
+
last_prepared: GeotabSession | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GeotabSessionAuth:
|
|
67
|
+
"""
|
|
68
|
+
Session auth for GeoTab: marries the session manager to the
|
|
69
|
+
``AuthStrategy`` protocol.
|
|
70
|
+
|
|
71
|
+
The last-prepared session lives in a ``threading.local`` slot
|
|
72
|
+
because the strategy instance is shared across worker threads.
|
|
73
|
+
prepare → send → on_auth_failure always executes within one thread,
|
|
74
|
+
but a plain instance attribute would let thread A's failure
|
|
75
|
+
invalidate the fresher session thread B just prepared with,
|
|
76
|
+
triggering a spurious refresh. The thread-local pins each failure
|
|
77
|
+
to the session that actually failed.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, manager: GeotabSessionManager) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Args:
|
|
83
|
+
manager: The single-flight session manager for this account.
|
|
84
|
+
"""
|
|
85
|
+
self._manager: GeotabSessionManager = manager
|
|
86
|
+
self._thread_local: _ThreadLocalSession = _ThreadLocalSession()
|
|
87
|
+
|
|
88
|
+
def prepare(self, spec: RequestSpec) -> RequestSpec:
|
|
89
|
+
"""
|
|
90
|
+
Inject session credentials into the JSON-RPC body and retarget
|
|
91
|
+
the URL to the session's resolved host.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
spec: A GeoTab JSON-RPC request spec; ``json_body`` is
|
|
95
|
+
required.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A new spec whose body carries ``params.credentials`` and
|
|
99
|
+
whose URL host is the session's resolved host (scheme,
|
|
100
|
+
path, and query untouched). The input spec and its body
|
|
101
|
+
are never mutated.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: If ``spec.json_body`` is None (every GeoTab
|
|
105
|
+
request is a JSON-RPC POST with a body), or if the
|
|
106
|
+
body's ``params`` exists but is not a mapping — both
|
|
107
|
+
are programming errors in the endpoint layer.
|
|
108
|
+
"""
|
|
109
|
+
# Validate before the session fetch: a body-less spec is a
|
|
110
|
+
# programming error and must not trigger a real Authenticate
|
|
111
|
+
# call (which would burn sessions against GeoTab's LRU cap).
|
|
112
|
+
if spec.json_body is None:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
'GeoTab requests require a JSON-RPC body; '
|
|
115
|
+
'a body-less spec reaching GeoTab auth is a programming error'
|
|
116
|
+
)
|
|
117
|
+
session: GeotabSession = self._manager.get_session()
|
|
118
|
+
|
|
119
|
+
# The strategy is the sole authority on credentials: an existing
|
|
120
|
+
# 'credentials' key left by a caller is overwritten, never kept.
|
|
121
|
+
credentials: dict[str, JsonValue] = {
|
|
122
|
+
'database': session.database,
|
|
123
|
+
'sessionId': session.session_id,
|
|
124
|
+
'userName': session.username,
|
|
125
|
+
}
|
|
126
|
+
if 'params' not in spec.json_body:
|
|
127
|
+
# Some JSON-RPC methods are parameterless apart from credentials.
|
|
128
|
+
new_params: dict[str, JsonValue] = {'credentials': credentials}
|
|
129
|
+
else:
|
|
130
|
+
existing_params: JsonValue = spec.json_body['params']
|
|
131
|
+
if not isinstance(existing_params, Mapping):
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"json_body['params'] must be a mapping, "
|
|
134
|
+
f'got {type(existing_params).__name__}'
|
|
135
|
+
)
|
|
136
|
+
new_params = {**existing_params, 'credentials': credentials}
|
|
137
|
+
new_body: dict[str, JsonValue] = {**spec.json_body, 'params': new_params}
|
|
138
|
+
|
|
139
|
+
# Authenticate may have redirected the session to a different
|
|
140
|
+
# server; every subsequent call must target it.
|
|
141
|
+
url_parts = urlsplit(spec.url)
|
|
142
|
+
retargeted_url: str = urlunsplit(
|
|
143
|
+
url_parts._replace(netloc=session.resolved_host)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
self._thread_local.last_prepared = session
|
|
147
|
+
return replace(spec, url=retargeted_url, json_body=new_body)
|
|
148
|
+
|
|
149
|
+
def on_auth_failure(self) -> bool:
|
|
150
|
+
"""
|
|
151
|
+
Invalidate the session this thread last prepared with; one
|
|
152
|
+
retry is worthwhile because fresh credentials are now cached.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True, always — the session manager guarantees a usable
|
|
156
|
+
replacement (or raises trying).
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
RuntimeError: If no session was ever prepared on this
|
|
160
|
+
thread — on_auth_failure without a prior prepare is a
|
|
161
|
+
client-logic bug.
|
|
162
|
+
"""
|
|
163
|
+
failed_session: GeotabSession | None = self._thread_local.last_prepared
|
|
164
|
+
if failed_session is None:
|
|
165
|
+
raise RuntimeError(
|
|
166
|
+
'on_auth_failure called before any prepare on this thread'
|
|
167
|
+
)
|
|
168
|
+
self._manager.invalidate(failed_session)
|
|
169
|
+
return True
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Per-provider response classifiers."""
|
|
2
|
+
|
|
3
|
+
from fleetpull.network.classifiers.geotab import GeotabResponseClassifier
|
|
4
|
+
from fleetpull.network.classifiers.motive import MotiveResponseClassifier
|
|
5
|
+
from fleetpull.network.classifiers.samsara import SamsaraResponseClassifier
|
|
6
|
+
|
|
7
|
+
__all__: list[str] = [
|
|
8
|
+
'GeotabResponseClassifier',
|
|
9
|
+
'MotiveResponseClassifier',
|
|
10
|
+
'SamsaraResponseClassifier',
|
|
11
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# src/fleetpull/network/classifiers/geotab.py
|
|
2
|
+
"""GeoTab response classifier (sources: scrubbed provider-behavior
|
|
3
|
+
verification, June 2026).
|
|
4
|
+
|
|
5
|
+
GeoTab is JSON-RPC: every application-level failure arrives inside
|
|
6
|
+
HTTP 200, so this classifier is envelope-driven — a status-code-only
|
|
7
|
+
client cannot see failure. The discriminator is ``error.data.type``
|
|
8
|
+
(observed present in every captured failure); classification never
|
|
9
|
+
reads human-readable message text. ``detail`` carries
|
|
10
|
+
``error['message']`` for humans, decisions never read it. Branch logic
|
|
11
|
+
deliberately resembles sibling classifiers without sharing code:
|
|
12
|
+
provider classifiers evolve independently (blast-radius over DRY).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
|
|
18
|
+
from fleetpull.network.contract import (
|
|
19
|
+
SERVER_ERROR_FLOOR,
|
|
20
|
+
ClassifiedResponse,
|
|
21
|
+
ResponseClassifier,
|
|
22
|
+
body_snippet,
|
|
23
|
+
retry_after_seconds_from_headers,
|
|
24
|
+
)
|
|
25
|
+
from fleetpull.vocabulary import JsonValue, ResponseCategory
|
|
26
|
+
|
|
27
|
+
__all__: list[str] = ['GeotabResponseClassifier']
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _detail_with_message(prefix: str, error_message: str | None) -> str:
|
|
31
|
+
"""Append the envelope's human-readable message when present."""
|
|
32
|
+
if error_message is None:
|
|
33
|
+
return prefix
|
|
34
|
+
return f'{prefix} ({error_message})'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _classify_error_envelope(
|
|
38
|
+
error_envelope: JsonValue, headers: Mapping[str, str]
|
|
39
|
+
) -> ClassifiedResponse:
|
|
40
|
+
"""Classify a JSON-RPC ``error`` member by ``data.type``."""
|
|
41
|
+
error_message: str | None = None
|
|
42
|
+
error_type: str | None = None
|
|
43
|
+
if isinstance(error_envelope, dict):
|
|
44
|
+
message_value: JsonValue = error_envelope.get('message')
|
|
45
|
+
if isinstance(message_value, str):
|
|
46
|
+
error_message = message_value
|
|
47
|
+
data_value: JsonValue = error_envelope.get('data')
|
|
48
|
+
if isinstance(data_value, dict):
|
|
49
|
+
type_value: JsonValue = data_value.get('type')
|
|
50
|
+
if isinstance(type_value, str):
|
|
51
|
+
error_type = type_value
|
|
52
|
+
|
|
53
|
+
if error_type is None:
|
|
54
|
+
return ClassifiedResponse(
|
|
55
|
+
category=ResponseCategory.FATAL,
|
|
56
|
+
detail=_detail_with_message(
|
|
57
|
+
'malformed GeoTab error envelope: missing data.type',
|
|
58
|
+
error_message,
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
match error_type:
|
|
63
|
+
case 'OverLimitException':
|
|
64
|
+
return ClassifiedResponse(
|
|
65
|
+
category=ResponseCategory.RATE_LIMITED,
|
|
66
|
+
retry_after_seconds=retry_after_seconds_from_headers(headers),
|
|
67
|
+
detail=error_message,
|
|
68
|
+
)
|
|
69
|
+
case 'InvalidUserException':
|
|
70
|
+
# Captured June 2026: bad credentials and dead sessions BOTH
|
|
71
|
+
# produce InvalidUserException, distinguished only by message
|
|
72
|
+
# text, which we never match on. Disambiguation is contextual
|
|
73
|
+
# and already structural: on a data call the auth strategy
|
|
74
|
+
# invalidates and retries once; on the Authenticate call
|
|
75
|
+
# itself the failure propagates out of the session manager
|
|
76
|
+
# as fatal.
|
|
77
|
+
return ClassifiedResponse(
|
|
78
|
+
category=ResponseCategory.AUTH_FAILURE,
|
|
79
|
+
detail=error_message,
|
|
80
|
+
)
|
|
81
|
+
case 'DbUnavailableException':
|
|
82
|
+
# Documented GeoTab transient.
|
|
83
|
+
return ClassifiedResponse(
|
|
84
|
+
category=ResponseCategory.TRANSIENT,
|
|
85
|
+
detail=error_message,
|
|
86
|
+
)
|
|
87
|
+
case _:
|
|
88
|
+
# Fail loud on exception types we have never met rather
|
|
89
|
+
# than guessing they are retryable.
|
|
90
|
+
return ClassifiedResponse(
|
|
91
|
+
category=ResponseCategory.FATAL,
|
|
92
|
+
detail=_detail_with_message(
|
|
93
|
+
f'unrecognized GeoTab exception type: {error_type}',
|
|
94
|
+
error_message,
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class GeotabResponseClassifier(ResponseClassifier):
|
|
100
|
+
"""Classifies GeoTab JSON-RPC responses by envelope inspection."""
|
|
101
|
+
|
|
102
|
+
def classify_response(
|
|
103
|
+
self,
|
|
104
|
+
status_code: int,
|
|
105
|
+
headers: Mapping[str, str],
|
|
106
|
+
body_text: str,
|
|
107
|
+
) -> ClassifiedResponse:
|
|
108
|
+
"""Classify one GeoTab response: status first, then envelope."""
|
|
109
|
+
if status_code >= SERVER_ERROR_FLOOR:
|
|
110
|
+
# Infrastructure ahead of the API; no body parsing needed.
|
|
111
|
+
return ClassifiedResponse(
|
|
112
|
+
category=ResponseCategory.TRANSIENT,
|
|
113
|
+
detail=f'HTTP {status_code}: {body_snippet(body_text)}',
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
parsed_body: JsonValue = json.loads(body_text)
|
|
118
|
+
except json.JSONDecodeError:
|
|
119
|
+
# Exactly what the load balancer's HTML error pages look
|
|
120
|
+
# like from here.
|
|
121
|
+
return ClassifiedResponse(
|
|
122
|
+
category=ResponseCategory.FATAL,
|
|
123
|
+
detail=(
|
|
124
|
+
f'HTTP {status_code}: unparseable (non-JSON) body: '
|
|
125
|
+
f'{body_snippet(body_text)}'
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if isinstance(parsed_body, dict):
|
|
130
|
+
if 'error' in parsed_body:
|
|
131
|
+
return _classify_error_envelope(parsed_body['error'], headers)
|
|
132
|
+
if 'result' in parsed_body:
|
|
133
|
+
# The parse already paid for classification is handed
|
|
134
|
+
# forward; the client must not parse the body again.
|
|
135
|
+
return ClassifiedResponse(
|
|
136
|
+
category=ResponseCategory.SUCCESS, parsed_body=parsed_body
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return ClassifiedResponse(
|
|
140
|
+
category=ResponseCategory.FATAL,
|
|
141
|
+
detail=(
|
|
142
|
+
'malformed GeoTab JSON-RPC envelope: neither result nor '
|
|
143
|
+
f'error present: {body_snippet(body_text)}'
|
|
144
|
+
),
|
|
145
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# src/fleetpull/network/classifiers/motive.py
|
|
2
|
+
"""Motive response classifier (sources: scrubbed provider-behavior
|
|
3
|
+
verification, June 2026; rate limiting probed and never observed —
|
|
4
|
+
generic HTTP posture).
|
|
5
|
+
|
|
6
|
+
Classification reads status codes and structured fields, never
|
|
7
|
+
human-readable message text; ``detail`` carries messages for humans,
|
|
8
|
+
decisions never read them. Branch logic deliberately resembles sibling
|
|
9
|
+
classifiers without sharing code: provider classifiers evolve
|
|
10
|
+
independently (blast-radius over DRY).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from http import HTTPStatus
|
|
16
|
+
|
|
17
|
+
from fleetpull.network.contract import (
|
|
18
|
+
SERVER_ERROR_FLOOR,
|
|
19
|
+
SUCCESS_STATUS_RANGE,
|
|
20
|
+
ClassifiedResponse,
|
|
21
|
+
ResponseClassifier,
|
|
22
|
+
body_snippet,
|
|
23
|
+
retry_after_seconds_from_headers,
|
|
24
|
+
)
|
|
25
|
+
from fleetpull.vocabulary import JsonValue, ResponseCategory
|
|
26
|
+
|
|
27
|
+
__all__: list[str] = ['MotiveResponseClassifier']
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _auth_failure_detail(body_text: str) -> str:
|
|
31
|
+
"""Extract the body's ``error_message`` when JSON, else a snippet."""
|
|
32
|
+
try:
|
|
33
|
+
parsed_body: JsonValue = json.loads(body_text)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
return body_snippet(body_text)
|
|
36
|
+
if isinstance(parsed_body, dict):
|
|
37
|
+
error_message: JsonValue = parsed_body.get('error_message')
|
|
38
|
+
if isinstance(error_message, str):
|
|
39
|
+
return error_message
|
|
40
|
+
return body_snippet(body_text)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MotiveResponseClassifier(ResponseClassifier):
|
|
44
|
+
"""Classifies Motive REST responses (observed shape:
|
|
45
|
+
``{"error_message": "invalid API key"}`` on 401)."""
|
|
46
|
+
|
|
47
|
+
def classify_response(
|
|
48
|
+
self,
|
|
49
|
+
status_code: int,
|
|
50
|
+
headers: Mapping[str, str],
|
|
51
|
+
body_text: str,
|
|
52
|
+
) -> ClassifiedResponse:
|
|
53
|
+
"""Classify one Motive response by status code."""
|
|
54
|
+
match status_code:
|
|
55
|
+
case code if code in SUCCESS_STATUS_RANGE:
|
|
56
|
+
return ClassifiedResponse(category=ResponseCategory.SUCCESS)
|
|
57
|
+
case HTTPStatus.TOO_MANY_REQUESTS:
|
|
58
|
+
# Motive rate limiting was probed and never observed
|
|
59
|
+
# (June 2026); this branch is built to the generic HTTP
|
|
60
|
+
# contract.
|
|
61
|
+
return ClassifiedResponse(
|
|
62
|
+
category=ResponseCategory.RATE_LIMITED,
|
|
63
|
+
retry_after_seconds=retry_after_seconds_from_headers(headers),
|
|
64
|
+
)
|
|
65
|
+
case HTTPStatus.UNAUTHORIZED | HTTPStatus.FORBIDDEN:
|
|
66
|
+
return ClassifiedResponse(
|
|
67
|
+
category=ResponseCategory.AUTH_FAILURE,
|
|
68
|
+
detail=_auth_failure_detail(body_text),
|
|
69
|
+
)
|
|
70
|
+
case code if code >= SERVER_ERROR_FLOOR:
|
|
71
|
+
return ClassifiedResponse(
|
|
72
|
+
category=ResponseCategory.TRANSIENT,
|
|
73
|
+
detail=f'HTTP {status_code}: {body_snippet(body_text)}',
|
|
74
|
+
)
|
|
75
|
+
case _:
|
|
76
|
+
return ClassifiedResponse(
|
|
77
|
+
category=ResponseCategory.FATAL,
|
|
78
|
+
detail=f'HTTP {status_code}: {body_snippet(body_text)}',
|
|
79
|
+
)
|