pgdispatch 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 (54) hide show
  1. pgdispatch/__init__.py +69 -0
  2. pgdispatch/__main__.py +5 -0
  3. pgdispatch/_version.py +1 -0
  4. pgdispatch/backends/__init__.py +6 -0
  5. pgdispatch/backends/asyncpg.py +103 -0
  6. pgdispatch/backends/base.py +142 -0
  7. pgdispatch/cli.py +420 -0
  8. pgdispatch/client.py +490 -0
  9. pgdispatch/config.py +223 -0
  10. pgdispatch/core/__init__.py +1 -0
  11. pgdispatch/core/circuit.py +94 -0
  12. pgdispatch/core/common.py +87 -0
  13. pgdispatch/core/deliveries.py +216 -0
  14. pgdispatch/core/endpoints.py +338 -0
  15. pgdispatch/core/events.py +65 -0
  16. pgdispatch/core/maintenance.py +121 -0
  17. pgdispatch/core/matching.py +76 -0
  18. pgdispatch/core/payload.py +108 -0
  19. pgdispatch/core/publisher.py +232 -0
  20. pgdispatch/core/retry.py +90 -0
  21. pgdispatch/core/secrets.py +138 -0
  22. pgdispatch/core/signing.py +141 -0
  23. pgdispatch/django/__init__.py +37 -0
  24. pgdispatch/django/apps.py +10 -0
  25. pgdispatch/django/client.py +402 -0
  26. pgdispatch/django/management/__init__.py +0 -0
  27. pgdispatch/django/management/commands/__init__.py +0 -0
  28. pgdispatch/django/management/commands/pgdispatch_worker.py +32 -0
  29. pgdispatch/django/migrations/0001_initial.py +30 -0
  30. pgdispatch/django/migrations/__init__.py +0 -0
  31. pgdispatch/errors.py +43 -0
  32. pgdispatch/fastapi/__init__.py +23 -0
  33. pgdispatch/fastapi/router.py +243 -0
  34. pgdispatch/hooks.py +80 -0
  35. pgdispatch/metrics.py +80 -0
  36. pgdispatch/migrations/0001_initial.sql +104 -0
  37. pgdispatch/migrations/__init__.py +93 -0
  38. pgdispatch/models.py +208 -0
  39. pgdispatch/py.typed +0 -0
  40. pgdispatch/security/__init__.py +1 -0
  41. pgdispatch/security/ssrf.py +141 -0
  42. pgdispatch/sqlalchemy.py +130 -0
  43. pgdispatch/worker/__init__.py +5 -0
  44. pgdispatch/worker/fetcher.py +183 -0
  45. pgdispatch/worker/ratelimit.py +47 -0
  46. pgdispatch/worker/recorder.py +435 -0
  47. pgdispatch/worker/runner.py +36 -0
  48. pgdispatch/worker/sender.py +160 -0
  49. pgdispatch/worker/worker.py +557 -0
  50. pgdispatch-0.1.0.dist-info/METADATA +329 -0
  51. pgdispatch-0.1.0.dist-info/RECORD +54 -0
  52. pgdispatch-0.1.0.dist-info/WHEEL +4 -0
  53. pgdispatch-0.1.0.dist-info/entry_points.txt +2 -0
  54. pgdispatch-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,47 @@
1
+ """Per-endpoint rate limiting without Redis.
2
+
3
+ Each worker keeps a GCRA (generic cell rate algorithm, an exact token bucket)
4
+ per rate-limited endpoint in memory. An endpoint's ``rate_limit_rps`` is split
5
+ evenly between the workers that are currently alive, which the workers learn
6
+ from their heartbeats. The result is approximate during scale-up and
7
+ scale-down, but needs no shared state beyond PostgreSQL.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from collections.abc import Callable, Hashable
14
+
15
+ __all__ = ["RateLimiter"]
16
+
17
+
18
+ class RateLimiter:
19
+ def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
20
+ self._clock = clock
21
+ # Theoretical arrival time of the next request, per key.
22
+ self._next_slot: dict[Hashable, float] = {}
23
+
24
+ def reserve(self, key: Hashable, rate: float) -> float:
25
+ """Reserve a slot for one request; return how many seconds to wait before sending.
26
+
27
+ Bursts of up to ``max(1, rate)`` requests (one second's worth) pass
28
+ without waiting.
29
+ """
30
+ now = self._clock()
31
+ interval = 1.0 / rate
32
+ burst_allowance = interval * (max(1.0, rate) - 1)
33
+ next_slot = max(self._next_slot.get(key, now), now)
34
+ delay = max(0.0, next_slot - burst_allowance - now)
35
+ self._next_slot[key] = next_slot + interval
36
+ return delay
37
+
38
+ def release(self, key: Hashable, rate: float) -> None:
39
+ """Give back a slot reserved with :meth:`reserve` that will not be used."""
40
+ if key in self._next_slot:
41
+ self._next_slot[key] -= 1.0 / rate
42
+
43
+ def prune(self) -> None:
44
+ """Forget keys that have been idle long enough to have a full burst again."""
45
+ now = self._clock()
46
+ for key in [key for key, slot in self._next_slot.items() if slot < now]:
47
+ del self._next_slot[key]
@@ -0,0 +1,435 @@
1
+ """Writing delivery results back to PostgreSQL in batches.
2
+
3
+ Send tasks hand their results to the recorder instead of writing them one by
4
+ one. The recorder flushes whatever has accumulated in a single transaction:
5
+
6
+ 1. lock the affected deliveries (in id order, so concurrent flushes cannot deadlock),
7
+ 2. update them, fenced by ``attempt_count`` so a stale result never wins,
8
+ 3. bulk-insert the attempt log,
9
+ 4. update the circuit breaker of every endpoint involved.
10
+
11
+ Under load this turns thousands of tiny transactions into a few large ones,
12
+ which is what keeps PostgreSQL from becoming the bottleneck.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import logging
20
+ from collections import defaultdict
21
+ from collections.abc import Callable, Mapping, Sequence
22
+ from dataclasses import dataclass, field
23
+ from datetime import datetime
24
+ from typing import Any
25
+
26
+ import asyncpg
27
+
28
+ from pgdispatch.backends.asyncpg import AsyncpgConnection
29
+ from pgdispatch.backends.base import Row, render_schema, to_numeric_style
30
+ from pgdispatch.core.circuit import (
31
+ BreakerPolicy,
32
+ BreakerState,
33
+ BreakerUpdate,
34
+ record_failure,
35
+ record_success,
36
+ )
37
+ from pgdispatch.core.common import ENDPOINT_COLUMNS
38
+ from pgdispatch.hooks import Hooks
39
+ from pgdispatch.metrics import Metrics
40
+ from pgdispatch.models import CircuitState, Delivery, DeliveryStatus, Endpoint, to_uuid
41
+ from pgdispatch.worker.fetcher import ClaimedDelivery
42
+ from pgdispatch.worker.sender import SendOutcome
43
+
44
+ __all__ = ["DeliveryRecord", "Recorder"]
45
+
46
+ logger = logging.getLogger("pgdispatch.worker")
47
+
48
+ MAX_FLUSH_SIZE = 1000
49
+ GONE_REASON = "the endpoint answered 410 Gone"
50
+ AUTO_DISABLE_REASON = "the endpoint has been failing continuously since {since}"
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class DeliveryRecord:
55
+ """The result of processing one claimed delivery."""
56
+
57
+ delivery: ClaimedDelivery
58
+ status: DeliveryStatus
59
+ """New status: ``pending`` means "try again in ``retry_in`` seconds"."""
60
+ retry_in: float = 0.0
61
+ outcome: SendOutcome | None = None
62
+ """``None`` when no request was made and the claim is simply released."""
63
+ request_headers: Mapping[str, str] = field(default_factory=dict)
64
+ attempted_at: datetime | None = None
65
+
66
+ @property
67
+ def released(self) -> bool:
68
+ return self.outcome is None
69
+
70
+
71
+ _LOCK_DELIVERIES = """
72
+ SELECT id FROM {schema}.deliveries
73
+ WHERE id = ANY(CAST(:ids AS bigint[]))
74
+ ORDER BY id
75
+ FOR UPDATE
76
+ """
77
+
78
+ _UPDATE_DELIVERIES = """
79
+ UPDATE {schema}.deliveries AS d
80
+ SET status = u.status,
81
+ attempt_count = CASE WHEN u.released THEN d.attempt_count - 1 ELSE d.attempt_count END,
82
+ next_attempt_at = CASE
83
+ WHEN u.status = 'pending' THEN now() + u.retry_in * interval '1 second'
84
+ ELSE d.next_attempt_at
85
+ END,
86
+ locked_until = NULL,
87
+ last_status_code = CASE WHEN u.released THEN d.last_status_code ELSE u.status_code END,
88
+ last_error = CASE WHEN u.released THEN d.last_error ELSE u.error END,
89
+ finished_at = CASE WHEN u.status IN ('succeeded', 'failed', 'cancelled') THEN now() END,
90
+ updated_at = now()
91
+ FROM unnest(
92
+ CAST(:ids AS bigint[]), CAST(:attempts AS integer[]), CAST(:statuses AS text[]),
93
+ CAST(:retry_in AS double precision[]), CAST(:status_codes AS integer[]),
94
+ CAST(:errors AS text[]), CAST(:released AS boolean[])
95
+ ) AS u(id, attempt, status, retry_in, status_code, error, released)
96
+ WHERE d.id = u.id AND d.status = 'in_flight' AND d.attempt_count = u.attempt
97
+ RETURNING d.id
98
+ """
99
+
100
+ _INSERT_ATTEMPTS = """
101
+ INSERT INTO {schema}.attempts
102
+ (delivery_id, attempt_number, request_headers, response_status, response_body,
103
+ duration_ms, error, created_at)
104
+ SELECT * FROM unnest(
105
+ CAST(:ids AS bigint[]), CAST(:numbers AS integer[]), CAST(:headers AS jsonb[]),
106
+ CAST(:statuses AS integer[]), CAST(:bodies AS text[]), CAST(:durations AS integer[]),
107
+ CAST(:errors AS text[]), CAST(:created AS timestamptz[])
108
+ )
109
+ """
110
+
111
+ # Endpoints that only saw successes and are already healthy need no update;
112
+ # skipping them keeps hot, healthy endpoints free of row-lock contention.
113
+ _LOCK_ENDPOINTS = """
114
+ SELECT now() AS now, e.id, e.is_enabled, e.circuit_state, e.consecutive_failures,
115
+ e.failing_since, e.circuit_opened_at, e.circuit_retry_at, e.circuit_trips
116
+ FROM {schema}.endpoints AS e
117
+ WHERE e.id = ANY(CAST(:ids AS uuid[]))
118
+ AND (
119
+ e.id = ANY(CAST(:failing_ids AS uuid[]))
120
+ OR e.circuit_state <> 'closed'
121
+ OR e.consecutive_failures > 0
122
+ )
123
+ ORDER BY e.id
124
+ FOR UPDATE
125
+ """
126
+
127
+ _UPDATE_ENDPOINTS = f"""
128
+ UPDATE {{schema}}.endpoints AS e
129
+ SET circuit_state = u.state,
130
+ consecutive_failures = u.failures,
131
+ failing_since = u.failing_since,
132
+ circuit_opened_at = u.opened_at,
133
+ circuit_retry_at = u.retry_at,
134
+ circuit_trips = u.trips,
135
+ is_enabled = e.is_enabled AND u.disable_reason IS NULL,
136
+ disabled_reason = CASE
137
+ WHEN e.is_enabled AND u.disable_reason IS NOT NULL THEN u.disable_reason
138
+ ELSE e.disabled_reason
139
+ END,
140
+ updated_at = now()
141
+ FROM unnest(
142
+ CAST(:ids AS uuid[]), CAST(:states AS text[]), CAST(:failures AS integer[]),
143
+ CAST(:failing_since AS timestamptz[]), CAST(:opened_at AS timestamptz[]),
144
+ CAST(:retry_at AS timestamptz[]), CAST(:trips AS integer[]), CAST(:disable_reasons AS text[])
145
+ ) AS u(id, state, failures, failing_since, opened_at, retry_at, trips, disable_reason)
146
+ WHERE e.id = u.id
147
+ RETURNING {ENDPOINT_COLUMNS}
148
+ """
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class _Entry:
153
+ record: DeliveryRecord
154
+ written: asyncio.Future[None]
155
+
156
+
157
+ @dataclass(slots=True)
158
+ class _Notifications:
159
+ """Hook calls to make once the flush has committed."""
160
+
161
+ failed: list[Delivery] = field(default_factory=list)
162
+ circuits_opened: list[Endpoint] = field(default_factory=list)
163
+ disabled: list[tuple[Endpoint, str]] = field(default_factory=list)
164
+
165
+
166
+ class Recorder:
167
+ def __init__(
168
+ self,
169
+ pool: asyncpg.Pool,
170
+ *,
171
+ schema: str,
172
+ policy: BreakerPolicy,
173
+ hooks: Hooks,
174
+ metrics: Metrics,
175
+ on_flush: Callable[[], None],
176
+ ) -> None:
177
+ self._pool = pool
178
+ self._schema = schema
179
+ self._policy = policy
180
+ self._hooks = hooks
181
+ self._metrics = metrics
182
+ self._on_flush = on_flush
183
+ self._queue: asyncio.Queue[_Entry | None] = asyncio.Queue()
184
+ self._give_up_at: float | None = None
185
+
186
+ def add(self, record: DeliveryRecord) -> asyncio.Future[None]:
187
+ """Queue a result; the returned future completes once it has been written."""
188
+ written: asyncio.Future[None] = asyncio.get_running_loop().create_future()
189
+ self._queue.put_nowait(_Entry(record, written))
190
+ return written
191
+
192
+ async def run(self) -> None:
193
+ """Flush records until :meth:`close` is called and everything is written."""
194
+ closing = False
195
+ while not closing:
196
+ first = await self._queue.get()
197
+ if first is None:
198
+ break
199
+ entries = [first]
200
+ while len(entries) < MAX_FLUSH_SIZE and not self._queue.empty():
201
+ entry = self._queue.get_nowait()
202
+ if entry is None:
203
+ closing = True
204
+ break
205
+ entries.append(entry)
206
+ await self._flush_with_retries([entry.record for entry in entries])
207
+ for entry in entries:
208
+ if not entry.written.done():
209
+ entry.written.set_result(None)
210
+ self._on_flush()
211
+
212
+ def close(self, *, timeout: float) -> None:
213
+ """Ask :meth:`run` to finish after writing everything queued so far."""
214
+ self._give_up_at = asyncio.get_running_loop().time() + timeout
215
+ self._queue.put_nowait(None)
216
+
217
+ async def _flush_with_retries(self, batch: Sequence[DeliveryRecord]) -> None:
218
+ delay = 0.5
219
+ while True:
220
+ try:
221
+ notifications = await self._flush(batch)
222
+ except (asyncpg.PostgresError, asyncpg.InterfaceError, OSError):
223
+ loop = asyncio.get_running_loop()
224
+ if self._give_up_at is not None and loop.time() + delay > self._give_up_at:
225
+ logger.exception(
226
+ "pgdispatch could not record %d results before shutdown; "
227
+ "their leases will expire and they will be sent again",
228
+ len(batch),
229
+ )
230
+ return
231
+ logger.warning(
232
+ "pgdispatch failed to record results, retrying in %.1fs", delay, exc_info=True
233
+ )
234
+ await asyncio.sleep(delay)
235
+ delay = min(delay * 2, 10.0)
236
+ except Exception:
237
+ # A bug must not stall the worker: the leases of these deliveries
238
+ # expire and they are sent again.
239
+ logger.exception("pgdispatch failed to record %d results", len(batch))
240
+ return
241
+ else:
242
+ await self._notify(notifications)
243
+ return
244
+
245
+ async def _flush(self, batch: Sequence[DeliveryRecord]) -> _Notifications:
246
+ notifications = _Notifications()
247
+ async with self._pool.acquire() as connection, connection.transaction():
248
+ # Losing the last few commits in a database crash only means those
249
+ # deliveries are sent again once their leases expire, which
250
+ # at-least-once delivery allows; in exchange there is no fsync wait.
251
+ await connection.execute("SET LOCAL synchronous_commit TO off")
252
+ rows = await self._fetch(
253
+ connection, _LOCK_DELIVERIES, ids=[record.delivery.id for record in batch]
254
+ )
255
+ existing = {row["id"] for row in rows}
256
+ applied = await self._update_deliveries(connection, batch)
257
+ await self._insert_attempts(connection, [r for r in batch if r.delivery.id in existing])
258
+ await self._update_endpoints(connection, batch, notifications)
259
+
260
+ for record in batch:
261
+ if record.status is DeliveryStatus.FAILED and record.delivery.id in applied:
262
+ self._metrics.deliveries_failed.inc()
263
+ notifications.failed.append(_failed_delivery(record))
264
+ return notifications
265
+
266
+ async def _update_deliveries(
267
+ self, connection: AsyncpgConnection, batch: Sequence[DeliveryRecord]
268
+ ) -> set[int]:
269
+ rows = await self._fetch(
270
+ connection,
271
+ _UPDATE_DELIVERIES,
272
+ ids=[r.delivery.id for r in batch],
273
+ attempts=[r.delivery.attempt_count for r in batch],
274
+ statuses=[r.status.value for r in batch],
275
+ retry_in=[r.retry_in for r in batch],
276
+ status_codes=[r.outcome.status_code if r.outcome else None for r in batch],
277
+ errors=[r.outcome.error if r.outcome else None for r in batch],
278
+ released=[r.released for r in batch],
279
+ )
280
+ applied = {row["id"] for row in rows}
281
+ stale = len(batch) - len(applied)
282
+ if stale:
283
+ # Possible if a lease expired mid-request (e.g. a long pause) and
284
+ # another worker already retried the delivery.
285
+ logger.warning("pgdispatch skipped %d stale delivery results", stale)
286
+ return applied
287
+
288
+ async def _insert_attempts(
289
+ self, connection: AsyncpgConnection, batch: Sequence[DeliveryRecord]
290
+ ) -> None:
291
+ attempts = [r for r in batch if r.outcome is not None]
292
+ if not attempts:
293
+ return
294
+ await self._fetch(
295
+ connection,
296
+ _INSERT_ATTEMPTS,
297
+ ids=[r.delivery.id for r in attempts],
298
+ numbers=[r.delivery.attempt_count for r in attempts],
299
+ headers=[json.dumps(dict(r.request_headers)) for r in attempts],
300
+ statuses=[r.outcome.status_code for r in attempts if r.outcome],
301
+ bodies=[r.outcome.response_body for r in attempts if r.outcome],
302
+ durations=[r.outcome.duration_ms for r in attempts if r.outcome],
303
+ errors=[r.outcome.error for r in attempts if r.outcome],
304
+ created=[r.attempted_at for r in attempts],
305
+ )
306
+
307
+ async def _update_endpoints(
308
+ self,
309
+ connection: AsyncpgConnection,
310
+ batch: Sequence[DeliveryRecord],
311
+ notifications: _Notifications,
312
+ ) -> None:
313
+ outcomes: dict[Any, list[DeliveryRecord]] = defaultdict(list)
314
+ for record in batch:
315
+ if record.outcome is not None:
316
+ outcomes[record.delivery.endpoint_id].append(record)
317
+ if not outcomes:
318
+ return
319
+ failing = {
320
+ record.delivery.endpoint_id
321
+ for record in batch
322
+ if record.outcome is not None and not record.outcome.succeeded
323
+ }
324
+ rows = await self._fetch(
325
+ connection,
326
+ _LOCK_ENDPOINTS,
327
+ ids=[str(i) for i in outcomes],
328
+ failing_ids=[str(i) for i in failing],
329
+ )
330
+
331
+ changes: list[tuple[Row, BreakerUpdate, str | None]] = []
332
+ for row in rows:
333
+ before = _breaker_state(row)
334
+ update = BreakerUpdate(before)
335
+ opened = False
336
+ gone = False
337
+ for record in outcomes[to_uuid(row["id"])]:
338
+ assert record.outcome is not None # noqa: S101 - filtered above
339
+ gone = gone or record.outcome.gone
340
+ if record.outcome.succeeded:
341
+ update = record_success(update.state)
342
+ else:
343
+ update = record_failure(update.state, now=row["now"], policy=self._policy)
344
+ opened = opened or update.opened
345
+ opened = opened and update.state.state is CircuitState.OPEN
346
+ update = BreakerUpdate(update.state, opened=opened, disable=update.disable)
347
+ reason = _disable_reason(row, update, gone=gone)
348
+ if update.state != before or reason is not None:
349
+ changes.append((row, update, reason))
350
+ if not changes:
351
+ return
352
+
353
+ updated = await self._fetch(
354
+ connection,
355
+ _UPDATE_ENDPOINTS,
356
+ ids=[str(row["id"]) for row, _, _ in changes],
357
+ states=[update.state.state.value for _, update, _ in changes],
358
+ failures=[update.state.consecutive_failures for _, update, _ in changes],
359
+ failing_since=[update.state.failing_since for _, update, _ in changes],
360
+ opened_at=[update.state.opened_at for _, update, _ in changes],
361
+ retry_at=[update.state.retry_at for _, update, _ in changes],
362
+ trips=[update.state.trips for _, update, _ in changes],
363
+ disable_reasons=[reason for _, _, reason in changes],
364
+ )
365
+ endpoints = {endpoint.id: endpoint for endpoint in map(Endpoint.from_row, updated)}
366
+ for row, update, reason in changes:
367
+ endpoint = endpoints[to_uuid(row["id"])]
368
+ if update.opened:
369
+ self._log_circuit_opened(endpoint, update)
370
+ notifications.circuits_opened.append(endpoint)
371
+ if reason is not None and row["is_enabled"]:
372
+ logger.warning(
373
+ "pgdispatch disabled endpoint %s: %s",
374
+ endpoint.id,
375
+ reason,
376
+ extra={"endpoint_id": str(endpoint.id)},
377
+ )
378
+ notifications.disabled.append((endpoint, reason))
379
+
380
+ async def _notify(self, notifications: _Notifications) -> None:
381
+ for delivery in notifications.failed:
382
+ await self._hooks.delivery_failed(delivery)
383
+ for endpoint in notifications.circuits_opened:
384
+ await self._hooks.circuit_opened(endpoint)
385
+ for endpoint, reason in notifications.disabled:
386
+ await self._hooks.endpoint_disabled(endpoint, reason)
387
+
388
+ async def _fetch(self, connection: AsyncpgConnection, sql: str, **params: Any) -> list[Row]:
389
+ statement, names = to_numeric_style(render_schema(sql, self._schema))
390
+ records = await connection.fetch(statement, *(params[name] for name in names))
391
+ return [dict(record) for record in records]
392
+
393
+ @staticmethod
394
+ def _log_circuit_opened(endpoint: Endpoint, update: BreakerUpdate) -> None:
395
+ logger.warning(
396
+ "pgdispatch opened the circuit breaker of endpoint %s after %d consecutive failures",
397
+ endpoint.id,
398
+ update.state.consecutive_failures,
399
+ extra={
400
+ "endpoint_id": str(endpoint.id),
401
+ "consecutive_failures": update.state.consecutive_failures,
402
+ "retry_at": update.state.retry_at.isoformat() if update.state.retry_at else None,
403
+ },
404
+ )
405
+
406
+
407
+ def _breaker_state(row: Row) -> BreakerState:
408
+ return BreakerState(
409
+ state=CircuitState(row["circuit_state"]),
410
+ consecutive_failures=row["consecutive_failures"],
411
+ failing_since=row["failing_since"],
412
+ opened_at=row["circuit_opened_at"],
413
+ retry_at=row["circuit_retry_at"],
414
+ trips=row["circuit_trips"],
415
+ )
416
+
417
+
418
+ def _disable_reason(row: Row, update: BreakerUpdate, *, gone: bool) -> str | None:
419
+ if not row["is_enabled"]:
420
+ return None
421
+ if gone:
422
+ return GONE_REASON
423
+ if update.disable and update.state.failing_since is not None:
424
+ return AUTO_DISABLE_REASON.format(since=update.state.failing_since.isoformat())
425
+ return None
426
+
427
+
428
+ def _failed_delivery(record: DeliveryRecord) -> Delivery:
429
+ outcome = record.outcome
430
+ return record.delivery.to_delivery(
431
+ status=DeliveryStatus.FAILED,
432
+ status_code=outcome.status_code if outcome else None,
433
+ error=outcome.error if outcome else None,
434
+ finished_at=record.attempted_at,
435
+ )
@@ -0,0 +1,36 @@
1
+ """Running a worker as the main program of a process (CLI, Django command)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import signal
9
+
10
+ from pgdispatch.worker.worker import Worker
11
+
12
+ __all__ = ["run_until_signalled"]
13
+
14
+ logger = logging.getLogger("pgdispatch.worker")
15
+
16
+
17
+ async def run_until_signalled(worker: Worker, *, metrics_port: int | None = None) -> None:
18
+ """Run ``worker`` until SIGINT or SIGTERM, then shut it down gracefully."""
19
+ if metrics_port is not None:
20
+ from prometheus_client import start_http_server
21
+
22
+ start_http_server(metrics_port)
23
+ logger.info("pgdispatch metrics are served on port %d", metrics_port)
24
+
25
+ loop = asyncio.get_running_loop()
26
+ for signum in (signal.SIGINT, signal.SIGTERM):
27
+ try:
28
+ loop.add_signal_handler(signum, worker.stop)
29
+ except (NotImplementedError, RuntimeError): # pragma: no cover - Windows
30
+ signal.signal(signum, lambda *_: loop.call_soon_threadsafe(worker.stop))
31
+ try:
32
+ await worker.run()
33
+ finally:
34
+ for signum in (signal.SIGINT, signal.SIGTERM):
35
+ with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
36
+ loop.remove_signal_handler(signum)
@@ -0,0 +1,160 @@
1
+ """Sending one HTTP request and classifying the receiver's answer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import enum
7
+ import time
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from datetime import timedelta
11
+
12
+ import aiohttp
13
+
14
+ from pgdispatch.core.retry import parse_retry_after
15
+ from pgdispatch.security.ssrf import BlockedAddressError, SafeResolver, check_url_host
16
+
17
+ __all__ = ["AttemptResult", "SendOutcome", "Sender", "create_session"]
18
+
19
+
20
+ class AttemptResult(str, enum.Enum):
21
+ """Coarse result of an attempt; used as the ``result`` metric label."""
22
+
23
+ SUCCESS = "success"
24
+ HTTP_ERROR = "http_error"
25
+ TIMEOUT = "timeout"
26
+ NETWORK = "network"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class SendOutcome:
31
+ result: AttemptResult
32
+ status_code: int | None
33
+ response_body: str | None
34
+ error: str | None
35
+ duration_ms: int
36
+ retry_after: timedelta | None = None
37
+
38
+ @property
39
+ def succeeded(self) -> bool:
40
+ return self.result is AttemptResult.SUCCESS
41
+
42
+ @property
43
+ def gone(self) -> bool:
44
+ """The receiver answered ``410 Gone``: it asks us to stop sending."""
45
+ return self.status_code == 410
46
+
47
+
48
+ def create_session(
49
+ *, concurrency: int, allow_private_ips: bool, timeout: timedelta
50
+ ) -> aiohttp.ClientSession:
51
+ """The HTTP session used by the worker.
52
+
53
+ Unless ``allow_private_ips`` is set, host names are resolved through
54
+ :class:`~pgdispatch.security.ssrf.SafeResolver`. Environment proxies are
55
+ ignored (``trust_env=False``) because they would bypass that filter; use
56
+ ``outbound_proxy`` instead.
57
+ """
58
+ connector = aiohttp.TCPConnector(
59
+ limit=concurrency,
60
+ resolver=None if allow_private_ips else SafeResolver(),
61
+ )
62
+ return aiohttp.ClientSession(
63
+ connector=connector,
64
+ timeout=aiohttp.ClientTimeout(total=timeout.total_seconds()),
65
+ trust_env=False,
66
+ )
67
+
68
+
69
+ class Sender:
70
+ """POSTs signed bodies; never follows redirects and never raises for HTTP failures."""
71
+
72
+ def __init__(
73
+ self,
74
+ session: aiohttp.ClientSession,
75
+ *,
76
+ timeout: timedelta,
77
+ store_response_body: bool,
78
+ max_response_body_bytes: int,
79
+ allow_private_ips: bool,
80
+ proxy: str | None = None,
81
+ ) -> None:
82
+ self._session = session
83
+ self._timeout = timeout.total_seconds()
84
+ self._store_body = store_response_body
85
+ self._max_body = max_response_body_bytes
86
+ self._check_host = not allow_private_ips and proxy is None
87
+ self._proxy = proxy
88
+
89
+ async def send(self, url: str, headers: Mapping[str, str], body: bytes) -> SendOutcome:
90
+ started = time.perf_counter()
91
+ try:
92
+ if self._check_host:
93
+ check_url_host(url)
94
+ # The session timeout covers the exchange; this also bounds DNS and
95
+ # anything else that might hang.
96
+ status, raw_body, retry_after = await asyncio.wait_for(
97
+ self._post(url, headers, body), self._timeout
98
+ )
99
+ except asyncio.TimeoutError:
100
+ return self._failure(
101
+ AttemptResult.TIMEOUT, f"timed out after {self._timeout:g}s", started
102
+ )
103
+ except BlockedAddressError as exc:
104
+ return self._failure(AttemptResult.NETWORK, f"blocked: {exc}", started)
105
+ except (aiohttp.ClientError, OSError) as exc:
106
+ return self._failure(AttemptResult.NETWORK, _describe(exc), started)
107
+
108
+ duration_ms = _elapsed_ms(started)
109
+ text = _decode(raw_body) if self._store_body else None
110
+ if 200 <= status < 300:
111
+ return SendOutcome(AttemptResult.SUCCESS, status, text, None, duration_ms)
112
+ if 300 <= status < 400:
113
+ error = f"HTTP {status}: redirects are not followed"
114
+ else:
115
+ error = f"HTTP {status}"
116
+ return SendOutcome(
117
+ AttemptResult.HTTP_ERROR,
118
+ status,
119
+ text,
120
+ error,
121
+ duration_ms,
122
+ retry_after=parse_retry_after(retry_after) if status in (429, 503) else None,
123
+ )
124
+
125
+ async def _post(
126
+ self, url: str, headers: Mapping[str, str], body: bytes
127
+ ) -> tuple[int, bytes, str | None]:
128
+ async with self._session.post(
129
+ url, data=body, headers=headers, allow_redirects=False, proxy=self._proxy
130
+ ) as response:
131
+ # Read at most max_body bytes: a hostile receiver must not be able
132
+ # to make the worker buffer an endless response.
133
+ content = b""
134
+ while len(content) < self._max_body:
135
+ chunk = await response.content.read(self._max_body - len(content))
136
+ if not chunk:
137
+ break
138
+ content += chunk
139
+ return response.status, content, response.headers.get("Retry-After")
140
+
141
+ @staticmethod
142
+ def _failure(result: AttemptResult, error: str, started: float) -> SendOutcome:
143
+ return SendOutcome(result, None, None, error, _elapsed_ms(started))
144
+
145
+
146
+ def _describe(exc: BaseException) -> str:
147
+ os_error = getattr(exc, "os_error", None)
148
+ if isinstance(os_error, BlockedAddressError):
149
+ return f"blocked: {os_error}"
150
+ message = str(exc) or type(exc).__name__
151
+ return f"{type(exc).__name__}: {message}"
152
+
153
+
154
+ def _decode(raw: bytes) -> str:
155
+ # PostgreSQL text cannot hold NUL bytes.
156
+ return raw.decode("utf-8", errors="replace").replace("\x00", "")
157
+
158
+
159
+ def _elapsed_ms(started: float) -> int:
160
+ return round((time.perf_counter() - started) * 1000)