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,153 @@
1
+ # src/fleetpull/network/limits/limiter.py
2
+ """Per-quota-scope rate limiter: token bucket + in-flight semaphore.
3
+
4
+ One ``QuotaScopeLimiter`` instance exists per quota scope, shared by every
5
+ thread in the process that talks to that scope. All time reads flow through
6
+ the injected ``Clock`` — never a direct ``time.*`` call — which is what makes
7
+ the deterministic single-threaded tests possible.
8
+ """
9
+
10
+ import logging
11
+ import threading
12
+ from collections.abc import Iterator
13
+ from contextlib import contextmanager
14
+
15
+ from fleetpull.config import RateLimitConfig
16
+ from fleetpull.network.limits.bucket_math import refill_tokens, seconds_until_available
17
+ from fleetpull.timing import Clock, SystemClock
18
+
19
+ __all__: list[str] = ['QuotaScopeLimiter']
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class QuotaScopeLimiter:
25
+ """Token-bucket rate limiter plus concurrency cap for one quota scope.
26
+
27
+ The bucket starts FULL at ``config.burst`` tokens (burst is the bucket
28
+ capacity), refills lazily at ``config.refill_rate_per_second``, and a
29
+ ``BoundedSemaphore`` caps requests in flight at ``config.max_concurrency``.
30
+ A 429 / Retry-After penalty pauses the whole scope via :meth:`penalize`.
31
+
32
+ Caller contracts (this object cannot enforce them):
33
+ - Every HTTP attempt consumes one slot. Retries re-acquire; every
34
+ page is an attempt.
35
+ - ``request_slot()`` wraps exactly one HTTP attempt — never a
36
+ pagination loop, never a retry loop.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ quota_scope: str,
42
+ config: RateLimitConfig,
43
+ clock: Clock = SystemClock(), # noqa: B008 — SystemClock is frozen and stateless; one shared default instance is intentional
44
+ ) -> None:
45
+ """Initialize a limiter with a full bucket and no penalty.
46
+
47
+ Args:
48
+ quota_scope: Scope name, used for log context (a penalty WARNING
49
+ that cannot say which scope was penalized is useless).
50
+ config: Token-bucket and concurrency settings for this scope.
51
+ clock: Time source; injected so tests can be deterministic.
52
+ """
53
+ self._quota_scope: str = quota_scope
54
+ self._config: RateLimitConfig = config
55
+ self._clock: Clock = clock
56
+ self._condition: threading.Condition = threading.Condition()
57
+ self._tokens: float = float(config.burst)
58
+ self._last_refill_monotonic: float = clock.monotonic_seconds()
59
+ self._pause_until: float = 0.0
60
+ # Bounded specifically: a double-release bug must raise, not silently
61
+ # inflate the concurrency budget.
62
+ self._in_flight_semaphore: threading.BoundedSemaphore = (
63
+ threading.BoundedSemaphore(config.max_concurrency)
64
+ )
65
+
66
+ @contextmanager
67
+ def request_slot(self) -> Iterator[None]:
68
+ """Block until one HTTP attempt may start, then hold its in-flight slot.
69
+
70
+ Acquires the concurrency semaphore first, then waits out any scope
71
+ penalty, then consumes one bucket token. Semaphore-first ordering is
72
+ deliberate: taking a token and then blocking on concurrency wastes
73
+ start-rate budget while starting nothing, while holding an idle
74
+ concurrency slot costs the provider nothing.
75
+
76
+ Yields:
77
+ None. The caller performs its single HTTP attempt inside the
78
+ ``with`` block.
79
+
80
+ Side Effects:
81
+ Blocks the calling thread; consumes one token; holds one
82
+ in-flight slot for the duration of the block. The slot is
83
+ released even if the block raises.
84
+ """
85
+ self._in_flight_semaphore.acquire()
86
+ try:
87
+ self._consume_token()
88
+ yield
89
+ finally:
90
+ self._in_flight_semaphore.release()
91
+
92
+ def penalize(self, seconds: float) -> None:
93
+ """Pause the entire quota scope, max-merged with any existing pause.
94
+
95
+ Called on 429 / Retry-After. The new pause is
96
+ ``max(pause_until, now + seconds)`` — never overwritten, so concurrent
97
+ 429s cannot shrink an existing penalty. All waiters are woken so they
98
+ recompute against the new penalty instead of firing the moment their
99
+ token math says go.
100
+
101
+ Args:
102
+ seconds: Pause duration (must be > 0). Clamping a zero or
103
+ negative Retry-After to something sane is the caller's job.
104
+
105
+ Raises:
106
+ ValueError: If ``seconds`` is not positive.
107
+
108
+ Side Effects:
109
+ Extends the scope-wide pause; wakes all waiting threads; logs a
110
+ WARNING naming the scope and the effective pause duration.
111
+ """
112
+ if seconds <= 0:
113
+ raise ValueError(f'penalty seconds must be positive, got {seconds}')
114
+ with self._condition:
115
+ now: float = self._clock.monotonic_seconds()
116
+ self._pause_until = max(self._pause_until, now + seconds)
117
+ effective_pause_seconds: float = self._pause_until - now
118
+ self._condition.notify_all()
119
+ logger.warning(
120
+ 'Quota scope %r penalized; paused for %.2f seconds.',
121
+ self._quota_scope,
122
+ effective_pause_seconds,
123
+ )
124
+
125
+ def _consume_token(self) -> None:
126
+ """Wait out any penalty, refill the bucket, and consume one token.
127
+
128
+ The penalty check comes BEFORE the token check on every iteration:
129
+ no token may be consumed while the scope is paused. Every wake
130
+ recomputes from scratch — never assume why you woke.
131
+ """
132
+ with self._condition:
133
+ while True:
134
+ now: float = self._clock.monotonic_seconds()
135
+ if now < self._pause_until:
136
+ self._condition.wait(timeout=self._pause_until - now)
137
+ continue
138
+ self._tokens = refill_tokens(
139
+ current_tokens=self._tokens,
140
+ elapsed_seconds=now - self._last_refill_monotonic,
141
+ refill_rate_per_second=self._config.refill_rate_per_second,
142
+ capacity=float(self._config.burst),
143
+ )
144
+ self._last_refill_monotonic = now
145
+ if self._tokens >= 1.0:
146
+ self._tokens -= 1.0
147
+ return
148
+ self._condition.wait(
149
+ timeout=seconds_until_available(
150
+ current_tokens=self._tokens,
151
+ refill_rate_per_second=self._config.refill_rate_per_second,
152
+ )
153
+ )
@@ -0,0 +1,96 @@
1
+ # src/fleetpull/network/limits/registry.py
2
+ """Process-wide map from quota-scope strings to their limiters, and the
3
+ derivation of its per-scope values from provider configs."""
4
+
5
+ import threading
6
+ from collections.abc import Iterable, Mapping
7
+
8
+ from fleetpull.config import ProviderConfig, RateLimitConfig
9
+ from fleetpull.exceptions import UnknownQuotaScopeError
10
+ from fleetpull.network.limits.limiter import QuotaScopeLimiter
11
+ from fleetpull.timing import Clock, SystemClock
12
+
13
+ __all__: list[str] = ['RateLimiterRegistry', 'rate_limits_from_configs']
14
+
15
+
16
+ def rate_limits_from_configs(
17
+ provider_configs: Iterable[ProviderConfig],
18
+ ) -> dict[str, RateLimitConfig]:
19
+ """Derive the registry's per-scope rate limits from provider configs.
20
+
21
+ Each provider config emits every scope its budgets govern
22
+ (``ProviderConfig.scope_rate_limits``: the one bound ``quota_scope`` in
23
+ the base, plus a multi-method-class provider's extra scopes -- GeoTab's
24
+ feed and authenticate classes -- in its override), so composition roots
25
+ hand this their resolved configs and never invent rate-limit numbers,
26
+ and no provider is special-cased here.
27
+
28
+ Args:
29
+ provider_configs: The resolved provider configs for this run -- the
30
+ same instances handed to ``build_endpoint_registry``.
31
+
32
+ Returns:
33
+ The quota-scope-keyed map ``RateLimiterRegistry`` is constructed on.
34
+ """
35
+ rate_limits: dict[str, RateLimitConfig] = {}
36
+ for config in provider_configs:
37
+ rate_limits.update(config.scope_rate_limits())
38
+ return rate_limits
39
+
40
+
41
+ class RateLimiterRegistry:
42
+ """Get-or-create registry of one ``QuotaScopeLimiter`` per quota scope.
43
+
44
+ The same scope string always returns the SAME limiter instance — two
45
+ limiters for one scope would silently double the request budget.
46
+
47
+ This registry is constructed by the application's composition root and
48
+ passed down to whatever needs it. It is NOT a module-level singleton.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ rate_limits: Mapping[str, RateLimitConfig],
54
+ clock: Clock = SystemClock(), # noqa: B008 — SystemClock is frozen and stateless; one shared default instance is intentional
55
+ ) -> None:
56
+ """Initialize the registry with the configured quota scopes.
57
+
58
+ Args:
59
+ rate_limits: Map of quota-scope string to its rate-limit config.
60
+ clock: Time source passed to every limiter this registry creates.
61
+ """
62
+ self._rate_limits: dict[str, RateLimitConfig] = dict(rate_limits)
63
+ self._clock: Clock = clock
64
+ self._limiters: dict[str, QuotaScopeLimiter] = {}
65
+ self._lock: threading.Lock = threading.Lock()
66
+
67
+ def get(self, quota_scope: str) -> QuotaScopeLimiter:
68
+ """Return the limiter for a scope, creating it on first request.
69
+
70
+ The check and the create both happen under the lock — losing the
71
+ check-then-act race would mean two limiters for one scope.
72
+
73
+ Args:
74
+ quota_scope: Scope name declared by an endpoint definition.
75
+
76
+ Returns:
77
+ The single ``QuotaScopeLimiter`` instance for this scope.
78
+
79
+ Raises:
80
+ UnknownQuotaScopeError: If the scope has no configured rate
81
+ limits.
82
+ """
83
+ with self._lock:
84
+ existing_limiter: QuotaScopeLimiter | None = self._limiters.get(quota_scope)
85
+ if existing_limiter is not None:
86
+ return existing_limiter
87
+ scope_config: RateLimitConfig | None = self._rate_limits.get(quota_scope)
88
+ if scope_config is None:
89
+ configured_scopes: str = ', '.join(sorted(self._rate_limits)) or 'none'
90
+ raise UnknownQuotaScopeError(
91
+ quota_scope,
92
+ detail=f'configured scopes: {configured_scopes}',
93
+ )
94
+ new_limiter = QuotaScopeLimiter(quota_scope, scope_config, self._clock)
95
+ self._limiters[quota_scope] = new_limiter
96
+ return new_limiter
@@ -0,0 +1,6 @@
1
+ # src/fleetpull/network/posture/__init__.py
2
+ """Transport posture: the one ``HttpConfig`` -> httpx client construction."""
3
+
4
+ from fleetpull.network.posture.client_options import new_http_client
5
+
6
+ __all__: list[str] = ['new_http_client']
@@ -0,0 +1,96 @@
1
+ # src/fleetpull/network/posture/client_options.py
2
+ """The one construction of an httpx client from ``HttpConfig``.
3
+
4
+ Every fleetpull ``httpx.Client`` — the transport's held pool and the
5
+ GeoTab authenticator's fresh per-call client — is constructed from the
6
+ same two-knob transport posture (DESIGN §10), so the verify/timeout
7
+ composition is decided exactly once, here (``new_http_client``). Before
8
+ this module existed the two construction sites hand-rolled the mapping
9
+ independently and drifted on the pool-timeout member; a single owner is
10
+ what keeps that class of divergence structurally impossible.
11
+ """
12
+
13
+ import ssl
14
+
15
+ import httpx
16
+
17
+ from fleetpull.config import HttpConfig
18
+ from fleetpull.network.tls import build_truststore_ssl_context
19
+
20
+ __all__: list[str] = ['new_http_client']
21
+
22
+
23
+ def _client_verify(http_config: HttpConfig) -> ssl.SSLContext | bool:
24
+ """Resolve the TLS verification argument for an ``httpx.Client``.
25
+
26
+ OS trust store behind a TLS-intercepting proxy; httpx's bundled CA
27
+ store otherwise (the proxy is the exception, not the rule).
28
+
29
+ Args:
30
+ http_config: The transport posture; ``use_truststore`` selects
31
+ the OS trust store.
32
+
33
+ Returns:
34
+ A truststore-backed ``SSLContext`` when ``use_truststore`` is
35
+ set; ``True`` (httpx's bundled CA verification) otherwise.
36
+
37
+ Raises:
38
+ None.
39
+
40
+ Side Effects:
41
+ None beyond constructing the SSL context.
42
+ """
43
+ if http_config.use_truststore:
44
+ return build_truststore_ssl_context()
45
+ return True
46
+
47
+
48
+ def _client_timeout(http_config: HttpConfig) -> httpx.Timeout:
49
+ """Resolve the timeout policy for an ``httpx.Client``.
50
+
51
+ The two-knob posture maps onto httpx's four members as: read backs
52
+ read, write, and pool (one slow-is-broken budget for every wait on
53
+ an established connection), while connect — the handshake — is its
54
+ own knob.
55
+
56
+ Args:
57
+ http_config: The transport posture supplying the two knobs.
58
+
59
+ Returns:
60
+ The ``httpx.Timeout`` carrying the policy above.
61
+
62
+ Raises:
63
+ None.
64
+
65
+ Side Effects:
66
+ None.
67
+ """
68
+ return httpx.Timeout(
69
+ http_config.read_timeout_seconds,
70
+ connect=http_config.connect_timeout_seconds,
71
+ )
72
+
73
+
74
+ def new_http_client(http_config: HttpConfig) -> httpx.Client:
75
+ """Construct an ``httpx.Client`` carrying the configured transport posture.
76
+
77
+ The one place the two-knob ``HttpConfig`` becomes httpx client options:
78
+ TLS verification (OS trust store behind a TLS-intercepting proxy, the
79
+ bundled CA store otherwise) and the timeout policy. Both fleetpull
80
+ construction sites — the transport's held pool and the GeoTab
81
+ authenticator's fresh per-call client — call this, so the composition
82
+ can never drift between them.
83
+
84
+ Args:
85
+ http_config: The transport posture.
86
+
87
+ Returns:
88
+ A new, open ``httpx.Client``; the caller owns its lifecycle.
89
+
90
+ Side Effects:
91
+ Constructs an httpx connection pool (opened lazily on first use).
92
+ """
93
+ return httpx.Client(
94
+ verify=_client_verify(http_config),
95
+ timeout=_client_timeout(http_config),
96
+ )
@@ -0,0 +1,13 @@
1
+ """Retry policy: the pure decision the client consults after each retryable failure."""
2
+
3
+ from fleetpull.network.retry.decision import (
4
+ RandomFractionGenerator,
5
+ RetryDecision,
6
+ decide_retry,
7
+ )
8
+
9
+ __all__: list[str] = [
10
+ 'RandomFractionGenerator',
11
+ 'RetryDecision',
12
+ 'decide_retry',
13
+ ]
@@ -0,0 +1,155 @@
1
+ # src/fleetpull/network/retry/decision.py
2
+ """The retry decision: pure policy, no loop, no sleep, no state.
3
+
4
+ The client asks one question after each retryable failure — "retry,
5
+ and with what local delay?" — and this module answers it. The answer
6
+ travels as a frozen ``RetryDecision`` rather than an overloaded
7
+ ``float | None``: the None-versus-0.0 distinction is exactly the kind
8
+ of subtle contract retry bugs breed in.
9
+
10
+ Division of labor (DESIGN §7): the limiter owns all rate-limit
11
+ waiting. RATE_LIMITED decisions therefore never carry a local delay —
12
+ the 429 penalized the shared quota scope, and the next
13
+ ``request_slot()`` call waits it out. Local delay exists only for
14
+ TRANSIENT backoff, computed here, slept by the client.
15
+ """
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Protocol
19
+
20
+ from fleetpull.config import RetryConfig
21
+ from fleetpull.vocabulary import ResponseCategory
22
+
23
+ __all__: list[str] = [
24
+ 'RandomFractionGenerator',
25
+ 'RetryDecision',
26
+ 'decide_retry',
27
+ ]
28
+
29
+
30
+ class RandomFractionGenerator(Protocol):
31
+ """
32
+ Generates random fractions for retry jitter.
33
+
34
+ The policy needs exactly one capability — a uniform fraction — so
35
+ the seam is one method, not a concrete randomness class (the Clock
36
+ precedent applied to jitter). ``random.Random`` satisfies this
37
+ protocol structurally; the composition root passes
38
+ ``random.Random()`` directly.
39
+ """
40
+
41
+ def random(self) -> float:
42
+ """Return a float in the half-open interval [0.0, 1.0)."""
43
+ ...
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class RetryDecision:
48
+ """
49
+ The answer to "retry, and with what local delay?".
50
+
51
+ Attributes:
52
+ should_retry: Whether the failure budget permits another
53
+ attempt.
54
+ local_delay_seconds: Seconds the client sleeps before the next
55
+ attempt. Meaningful only when ``should_retry`` is True;
56
+ 0.0 otherwise (the established inert-field pattern).
57
+ Always 0.0 for RATE_LIMITED — the penalized limiter is
58
+ that category's backoff.
59
+ """
60
+
61
+ should_retry: bool
62
+ local_delay_seconds: float
63
+
64
+
65
+ def _transient_delay_seconds(
66
+ failure_count: int,
67
+ config: RetryConfig,
68
+ random_source: RandomFractionGenerator,
69
+ ) -> float:
70
+ """
71
+ Full-jitter exponential backoff for one TRANSIENT failure.
72
+
73
+ Args:
74
+ failure_count: One-based failure count within TRANSIENT.
75
+ config: The retry policy.
76
+ random_source: Injected randomness seam; stubs make the
77
+ arithmetic exactly assertable in tests.
78
+
79
+ Returns:
80
+ A delay drawn uniformly from the half-open interval
81
+ ``[0, min(cap, base * 2 ** (failure_count - 1)))`` — the
82
+ fraction source is ``[0.0, 1.0)``, so the delay never equals
83
+ the envelope; the same arithmetic ``random.uniform`` performs
84
+ internally.
85
+ """
86
+ envelope_seconds: float = min(
87
+ config.transient_backoff_cap_seconds,
88
+ config.transient_backoff_base_seconds * 2 ** (failure_count - 1),
89
+ )
90
+ return envelope_seconds * random_source.random()
91
+
92
+
93
+ def decide_retry(
94
+ category: ResponseCategory,
95
+ failure_count: int,
96
+ config: RetryConfig,
97
+ random_source: RandomFractionGenerator,
98
+ ) -> RetryDecision:
99
+ """
100
+ Decide whether a retryable failure gets another attempt.
101
+
102
+ ``failure_count`` is one-based within the current retryable
103
+ category: the first TRANSIENT failure of an attempt sequence is 1.
104
+ Category counters are independent — the caller keeps one per
105
+ category and neither resets the other. The comparison is
106
+ ``failure_count > max_failures``, so ``max_failures = N`` retries
107
+ failures 1..N and exhausts on the (N+1)th: at most N + 1 requests.
108
+
109
+ Args:
110
+ category: The classification of the failure. Only TRANSIENT
111
+ and RATE_LIMITED are retryable; anything else here is a
112
+ caller bug.
113
+ failure_count: One-based failure count within ``category``.
114
+ config: The retry policy.
115
+ random_source: Injected randomness seam for jitter. Required —
116
+ implementations are stateful, so the parameter earns no
117
+ frozen-and-stateless default; the composition root owns
118
+ one instance (``random.Random()`` satisfies the protocol
119
+ structurally).
120
+
121
+ Returns:
122
+ The decision. Exhausted budgets return
123
+ ``RetryDecision(should_retry=False, local_delay_seconds=0.0)``;
124
+ the caller raises ``RetriesExhaustedError`` with the terminal
125
+ failure count as ``attempt_count`` (equal by definition —
126
+ every attempt failed).
127
+
128
+ Raises:
129
+ ValueError: When ``category`` is not retryable or
130
+ ``failure_count`` is less than 1 — programming errors,
131
+ deliberately stdlib per the hierarchy's closure stance.
132
+ """
133
+ if failure_count < 1:
134
+ raise ValueError(f'failure_count is one-based; got {failure_count}')
135
+ match category:
136
+ case ResponseCategory.TRANSIENT:
137
+ if failure_count > config.transient_max_failures:
138
+ return RetryDecision(should_retry=False, local_delay_seconds=0.0)
139
+ return RetryDecision(
140
+ should_retry=True,
141
+ local_delay_seconds=_transient_delay_seconds(
142
+ failure_count, config, random_source
143
+ ),
144
+ )
145
+ case ResponseCategory.RATE_LIMITED:
146
+ if failure_count > config.rate_limited_max_failures:
147
+ return RetryDecision(should_retry=False, local_delay_seconds=0.0)
148
+ # The limiter already absorbed this 429's penalty; the
149
+ # retry waits in request_slot(), never here.
150
+ return RetryDecision(should_retry=True, local_delay_seconds=0.0)
151
+ case _:
152
+ raise ValueError(
153
+ f'category {category!r} is not retryable; only TRANSIENT '
154
+ 'and RATE_LIMITED reach retry policy'
155
+ )
@@ -0,0 +1,5 @@
1
+ """TLS plumbing: SSL-context construction for the OS trust store."""
2
+
3
+ from fleetpull.network.tls.truststore_context import build_truststore_ssl_context
4
+
5
+ __all__: list[str] = ['build_truststore_ssl_context']
@@ -0,0 +1,35 @@
1
+ # src/fleetpull/network/tls/truststore_context.py
2
+ """SSL context factory using the operating system trust store.
3
+
4
+ Builds SSL contexts that verify certificates against the OS-native
5
+ trust store rather than Python's bundled certifi certificates. The
6
+ primary use case is corporate environments using aggressive proxies
7
+ (like Zscaler) that act as man-in-the-middle inspectors: these proxies
8
+ re-encrypt traffic with a private root CA that is installed in the
9
+ Windows/macOS system store but is unknown to standard Python
10
+ libraries. Without this module, requests fail with
11
+ ``SSLCertVerificationError``; with it, Python trusts the proxy's root
12
+ CA, enabling secure connectivity without disabling SSL verification.
13
+ """
14
+
15
+ import ssl
16
+ from ssl import SSLContext
17
+
18
+ import truststore
19
+
20
+ __all__: list[str] = ['build_truststore_ssl_context']
21
+
22
+
23
+ def build_truststore_ssl_context() -> SSLContext:
24
+ """
25
+ Create an SSLContext using truststore for system certificate validation.
26
+
27
+ Explicitly uses ``PROTOCOL_TLS_CLIENT`` — secure defaults for
28
+ client-side TLS with automatic protocol negotiation and certificate
29
+ verification. Safe for library code: no global monkey-patching.
30
+
31
+ Returns:
32
+ A new context that validates certificates against the OS trust
33
+ store.
34
+ """
35
+ return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
@@ -0,0 +1,30 @@
1
+ # src/fleetpull/orchestrator/__init__.py
2
+ """The orchestration layer's face: what the composition roots consume (DESIGN §14).
3
+
4
+ ``run_endpoint`` is the caller boundary: it resolves the endpoint's declared
5
+ request driver through the shared shape seam (``resolve_request_driver`` --
6
+ the one match over the ``RequestShape`` union, called by both composition
7
+ roots) and runs. ``EndpointRunner`` owns one endpoint's run and dispatches on
8
+ its sync mode, constructed on the ``RunStateAccess`` state bundle;
9
+ ``RosterMachinery`` bundles the roster collaborators a run consults and
10
+ ``RosterRefreshCoordinator`` owns the roster staleness policy whole;
11
+ ``FetchPoolRegistry`` owns one fan-out worker pool per provider (DESIGN §7).
12
+ Everything else in the package -- the drives, the drivers, the outcome union,
13
+ the unit loop -- is internal machinery its modules export directly."""
14
+
15
+ from fleetpull.orchestrator.entry import RosterMachinery, run_endpoint
16
+ from fleetpull.orchestrator.executors import FetchPoolRegistry
17
+ from fleetpull.orchestrator.roster_refresh import RosterRefreshCoordinator
18
+ from fleetpull.orchestrator.runner import EndpointRunner
19
+ from fleetpull.orchestrator.shape_resolution import resolve_request_driver
20
+ from fleetpull.orchestrator.spine import RunStateAccess
21
+
22
+ __all__: list[str] = [
23
+ 'EndpointRunner',
24
+ 'FetchPoolRegistry',
25
+ 'RosterMachinery',
26
+ 'RosterRefreshCoordinator',
27
+ 'RunStateAccess',
28
+ 'resolve_request_driver',
29
+ 'run_endpoint',
30
+ ]