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,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,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,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
|
+
]
|