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.
Files changed (252) hide show
  1. fleetpull/__init__.py +36 -0
  2. fleetpull/api/__init__.py +32 -0
  3. fleetpull/api/auth_ingress.py +216 -0
  4. fleetpull/api/catalog.py +139 -0
  5. fleetpull/api/fetch.py +229 -0
  6. fleetpull/api/identity.py +90 -0
  7. fleetpull/api/sync.py +735 -0
  8. fleetpull/cli.py +146 -0
  9. fleetpull/config/__init__.py +47 -0
  10. fleetpull/config/base.py +18 -0
  11. fleetpull/config/example.py +90 -0
  12. fleetpull/config/geotab.py +78 -0
  13. fleetpull/config/http.py +32 -0
  14. fleetpull/config/loading.py +195 -0
  15. fleetpull/config/logger.py +139 -0
  16. fleetpull/config/providers.py +520 -0
  17. fleetpull/config/rate_limit.py +50 -0
  18. fleetpull/config/resolution.py +156 -0
  19. fleetpull/config/retry.py +69 -0
  20. fleetpull/config/root.py +157 -0
  21. fleetpull/config/sections.py +146 -0
  22. fleetpull/endpoints/__init__.py +24 -0
  23. fleetpull/endpoints/geotab/__init__.py +12 -0
  24. fleetpull/endpoints/geotab/_requests.py +427 -0
  25. fleetpull/endpoints/geotab/annotation_logs.py +73 -0
  26. fleetpull/endpoints/geotab/audits.py +70 -0
  27. fleetpull/endpoints/geotab/devices.py +79 -0
  28. fleetpull/endpoints/geotab/driver_changes.py +73 -0
  29. fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
  30. fleetpull/endpoints/geotab/dvir_logs.py +76 -0
  31. fleetpull/endpoints/geotab/exception_events.py +119 -0
  32. fleetpull/endpoints/geotab/fault_data.py +72 -0
  33. fleetpull/endpoints/geotab/fill_ups.py +79 -0
  34. fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
  35. fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
  36. fleetpull/endpoints/geotab/log_records.py +74 -0
  37. fleetpull/endpoints/geotab/media_files.py +74 -0
  38. fleetpull/endpoints/geotab/shipment_logs.py +71 -0
  39. fleetpull/endpoints/geotab/status_data.py +71 -0
  40. fleetpull/endpoints/geotab/text_messages.py +77 -0
  41. fleetpull/endpoints/geotab/trips.py +102 -0
  42. fleetpull/endpoints/geotab/users.py +81 -0
  43. fleetpull/endpoints/motive/__init__.py +12 -0
  44. fleetpull/endpoints/motive/_spec_builders.py +94 -0
  45. fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
  46. fleetpull/endpoints/motive/driving_periods.py +85 -0
  47. fleetpull/endpoints/motive/groups.py +61 -0
  48. fleetpull/endpoints/motive/idle_events.py +94 -0
  49. fleetpull/endpoints/motive/users.py +64 -0
  50. fleetpull/endpoints/motive/vehicle_locations.py +170 -0
  51. fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
  52. fleetpull/endpoints/motive/vehicles.py +106 -0
  53. fleetpull/endpoints/registry.py +280 -0
  54. fleetpull/endpoints/samsara/__init__.py +12 -0
  55. fleetpull/endpoints/samsara/_spec_builders.py +202 -0
  56. fleetpull/endpoints/samsara/addresses.py +77 -0
  57. fleetpull/endpoints/samsara/asset_locations.py +217 -0
  58. fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
  59. fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
  60. fleetpull/endpoints/samsara/drivers.py +169 -0
  61. fleetpull/endpoints/samsara/engine_states.py +114 -0
  62. fleetpull/endpoints/samsara/gps_readings.py +113 -0
  63. fleetpull/endpoints/samsara/idling_events.py +195 -0
  64. fleetpull/endpoints/samsara/odometer_readings.py +116 -0
  65. fleetpull/endpoints/samsara/trips.py +217 -0
  66. fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
  67. fleetpull/endpoints/samsara/vehicles.py +121 -0
  68. fleetpull/endpoints/shared/__init__.py +57 -0
  69. fleetpull/endpoints/shared/base.py +529 -0
  70. fleetpull/endpoints/shared/request_shape.py +227 -0
  71. fleetpull/endpoints/shared/resume.py +74 -0
  72. fleetpull/endpoints/shared/spec_builders.py +59 -0
  73. fleetpull/endpoints/shared/sync_mode.py +159 -0
  74. fleetpull/endpoints/shared/url_paths.py +149 -0
  75. fleetpull/exceptions.py +320 -0
  76. fleetpull/incremental/__init__.py +27 -0
  77. fleetpull/incremental/cursor.py +66 -0
  78. fleetpull/incremental/resolution.py +152 -0
  79. fleetpull/incremental/seed.py +53 -0
  80. fleetpull/incremental/window.py +94 -0
  81. fleetpull/logger/__init__.py +5 -0
  82. fleetpull/logger/setup.py +145 -0
  83. fleetpull/model_contract/__init__.py +6 -0
  84. fleetpull/model_contract/coercions.py +33 -0
  85. fleetpull/model_contract/response.py +45 -0
  86. fleetpull/models/__init__.py +7 -0
  87. fleetpull/models/geotab/__init__.py +166 -0
  88. fleetpull/models/geotab/annotation_log.py +109 -0
  89. fleetpull/models/geotab/audit.py +56 -0
  90. fleetpull/models/geotab/device.py +217 -0
  91. fleetpull/models/geotab/driver_change.py +102 -0
  92. fleetpull/models/geotab/duty_status_log.py +200 -0
  93. fleetpull/models/geotab/dvir_log.py +184 -0
  94. fleetpull/models/geotab/exception_event.py +135 -0
  95. fleetpull/models/geotab/fault_data.py +168 -0
  96. fleetpull/models/geotab/fill_up.py +189 -0
  97. fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
  98. fleetpull/models/geotab/fuel_tax_detail.py +150 -0
  99. fleetpull/models/geotab/log_record.py +68 -0
  100. fleetpull/models/geotab/media_file.py +133 -0
  101. fleetpull/models/geotab/shared.py +221 -0
  102. fleetpull/models/geotab/shipment_log.py +105 -0
  103. fleetpull/models/geotab/status_data.py +108 -0
  104. fleetpull/models/geotab/text_message.py +125 -0
  105. fleetpull/models/geotab/trip.py +162 -0
  106. fleetpull/models/geotab/user.py +187 -0
  107. fleetpull/models/motive/__init__.py +43 -0
  108. fleetpull/models/motive/driver_idle_rollup.py +109 -0
  109. fleetpull/models/motive/driving_period.py +82 -0
  110. fleetpull/models/motive/group.py +49 -0
  111. fleetpull/models/motive/idle_event.py +77 -0
  112. fleetpull/models/motive/shared.py +192 -0
  113. fleetpull/models/motive/user.py +217 -0
  114. fleetpull/models/motive/vehicle.py +162 -0
  115. fleetpull/models/motive/vehicle_location.py +127 -0
  116. fleetpull/models/motive/vehicle_utilization.py +128 -0
  117. fleetpull/models/samsara/__init__.py +108 -0
  118. fleetpull/models/samsara/address.py +149 -0
  119. fleetpull/models/samsara/asset_location.py +131 -0
  120. fleetpull/models/samsara/driver.py +232 -0
  121. fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
  122. fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
  123. fleetpull/models/samsara/engine_state.py +92 -0
  124. fleetpull/models/samsara/gps_reading.py +146 -0
  125. fleetpull/models/samsara/idling_event.py +174 -0
  126. fleetpull/models/samsara/odometer_reading.py +90 -0
  127. fleetpull/models/samsara/trip.py +199 -0
  128. fleetpull/models/samsara/vehicle.py +167 -0
  129. fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
  130. fleetpull/network/__init__.py +9 -0
  131. fleetpull/network/auth/__init__.py +15 -0
  132. fleetpull/network/auth/authenticate.py +271 -0
  133. fleetpull/network/auth/manager.py +223 -0
  134. fleetpull/network/auth/models.py +57 -0
  135. fleetpull/network/auth/strategies.py +169 -0
  136. fleetpull/network/classifiers/__init__.py +11 -0
  137. fleetpull/network/classifiers/geotab.py +145 -0
  138. fleetpull/network/classifiers/motive.py +79 -0
  139. fleetpull/network/classifiers/samsara.py +80 -0
  140. fleetpull/network/client/__init__.py +25 -0
  141. fleetpull/network/client/page.py +32 -0
  142. fleetpull/network/client/profile.py +27 -0
  143. fleetpull/network/client/registry.py +103 -0
  144. fleetpull/network/client/registry_base.py +150 -0
  145. fleetpull/network/client/runtime.py +44 -0
  146. fleetpull/network/client/transport.py +381 -0
  147. fleetpull/network/contract/__init__.py +50 -0
  148. fleetpull/network/contract/auth.py +48 -0
  149. fleetpull/network/contract/classifier.py +189 -0
  150. fleetpull/network/contract/envelope_fetcher.py +33 -0
  151. fleetpull/network/contract/envelopes.py +189 -0
  152. fleetpull/network/contract/outcome.py +43 -0
  153. fleetpull/network/contract/page_decoder.py +101 -0
  154. fleetpull/network/contract/request.py +97 -0
  155. fleetpull/network/decoders/__init__.py +40 -0
  156. fleetpull/network/decoders/_window_stamp.py +78 -0
  157. fleetpull/network/decoders/geotab.py +309 -0
  158. fleetpull/network/decoders/motive.py +204 -0
  159. fleetpull/network/decoders/motive_reports.py +114 -0
  160. fleetpull/network/decoders/samsara.py +343 -0
  161. fleetpull/network/decoders/samsara_reports.py +119 -0
  162. fleetpull/network/decoders/single_page.py +55 -0
  163. fleetpull/network/limits/__init__.py +13 -0
  164. fleetpull/network/limits/bucket_math.py +58 -0
  165. fleetpull/network/limits/limiter.py +153 -0
  166. fleetpull/network/limits/registry.py +96 -0
  167. fleetpull/network/posture/__init__.py +6 -0
  168. fleetpull/network/posture/client_options.py +96 -0
  169. fleetpull/network/retry/__init__.py +13 -0
  170. fleetpull/network/retry/decision.py +155 -0
  171. fleetpull/network/tls/__init__.py +5 -0
  172. fleetpull/network/tls/truststore_context.py +35 -0
  173. fleetpull/orchestrator/__init__.py +30 -0
  174. fleetpull/orchestrator/backfill.py +127 -0
  175. fleetpull/orchestrator/batch.py +155 -0
  176. fleetpull/orchestrator/bisection.py +271 -0
  177. fleetpull/orchestrator/drivers.py +350 -0
  178. fleetpull/orchestrator/entry.py +347 -0
  179. fleetpull/orchestrator/executors.py +128 -0
  180. fleetpull/orchestrator/fanout.py +163 -0
  181. fleetpull/orchestrator/feed_drive.py +266 -0
  182. fleetpull/orchestrator/metadata_projection.py +195 -0
  183. fleetpull/orchestrator/outcome.py +53 -0
  184. fleetpull/orchestrator/recording.py +85 -0
  185. fleetpull/orchestrator/resume.py +143 -0
  186. fleetpull/orchestrator/roster_harvest.py +108 -0
  187. fleetpull/orchestrator/roster_refresh.py +363 -0
  188. fleetpull/orchestrator/runner.py +262 -0
  189. fleetpull/orchestrator/shape_resolution.py +221 -0
  190. fleetpull/orchestrator/spine.py +166 -0
  191. fleetpull/orchestrator/streaming.py +153 -0
  192. fleetpull/orchestrator/unit_loop.py +273 -0
  193. fleetpull/orchestrator/watermark_drive.py +455 -0
  194. fleetpull/paths/__init__.py +13 -0
  195. fleetpull/paths/datasets.py +38 -0
  196. fleetpull/paths/partitions.py +41 -0
  197. fleetpull/paths/resolution.py +98 -0
  198. fleetpull/polars_typing/__init__.py +20 -0
  199. fleetpull/py.typed +0 -0
  200. fleetpull/records/__init__.py +19 -0
  201. fleetpull/records/convert.py +53 -0
  202. fleetpull/records/dataframe.py +63 -0
  203. fleetpull/records/event_time.py +70 -0
  204. fleetpull/records/fields.py +190 -0
  205. fleetpull/records/flatten.py +54 -0
  206. fleetpull/records/roster_members.py +74 -0
  207. fleetpull/records/schema.py +73 -0
  208. fleetpull/records/validation.py +62 -0
  209. fleetpull/resources/__init__.py +10 -0
  210. fleetpull/resources/config.example.yaml +222 -0
  211. fleetpull/roster/__init__.py +16 -0
  212. fleetpull/roster/definition.py +46 -0
  213. fleetpull/roster/key.py +40 -0
  214. fleetpull/roster/registry.py +93 -0
  215. fleetpull/state/__init__.py +36 -0
  216. fleetpull/state/cursors.py +417 -0
  217. fleetpull/state/database.py +463 -0
  218. fleetpull/state/migrations.py +430 -0
  219. fleetpull/state/reconcile.py +127 -0
  220. fleetpull/state/rosters.py +159 -0
  221. fleetpull/state/run_ledger.py +572 -0
  222. fleetpull/state/work_units.py +648 -0
  223. fleetpull/storage/__init__.py +30 -0
  224. fleetpull/storage/append.py +187 -0
  225. fleetpull/storage/atomic.py +161 -0
  226. fleetpull/storage/files.py +176 -0
  227. fleetpull/storage/frames.py +98 -0
  228. fleetpull/storage/metadata.py +161 -0
  229. fleetpull/storage/partitioned.py +205 -0
  230. fleetpull/storage/pruning.py +169 -0
  231. fleetpull/storage/read.py +35 -0
  232. fleetpull/storage/result.py +34 -0
  233. fleetpull/storage/single_file.py +130 -0
  234. fleetpull/storage/splitting.py +88 -0
  235. fleetpull/storage/staging.py +178 -0
  236. fleetpull/storage/writers.py +145 -0
  237. fleetpull/timing/__init__.py +21 -0
  238. fleetpull/timing/canon.py +92 -0
  239. fleetpull/timing/clock.py +192 -0
  240. fleetpull/timing/codec.py +122 -0
  241. fleetpull/timing/sleeper.py +65 -0
  242. fleetpull/vocabulary/__init__.py +16 -0
  243. fleetpull/vocabulary/json_types.py +20 -0
  244. fleetpull/vocabulary/provider.py +29 -0
  245. fleetpull/vocabulary/quota_scope.py +47 -0
  246. fleetpull/vocabulary/response_category.py +32 -0
  247. fleetpull-0.1.0.dist-info/METADATA +248 -0
  248. fleetpull-0.1.0.dist-info/RECORD +252 -0
  249. fleetpull-0.1.0.dist-info/WHEEL +5 -0
  250. fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
  251. fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
  252. 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