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
pgdispatch/cli.py ADDED
@@ -0,0 +1,420 @@
1
+ """The ``pgdispatch`` command-line tool.
2
+
3
+ ::
4
+
5
+ pgdispatch migrate # create or upgrade the schema
6
+ pgdispatch worker --concurrency 50 # deliver webhooks
7
+ pgdispatch status # queue, lag, open circuit breakers
8
+ pgdispatch deliveries --status failed --since 1h
9
+ pgdispatch deliveries show 18342 # every attempt with responses
10
+ pgdispatch retry 18342 # retry one delivery
11
+ pgdispatch retry --endpoint <id> # retry all failed deliveries of an endpoint
12
+ pgdispatch replay --event <id> [--endpoint <id>]
13
+ pgdispatch endpoints list --owner merchant_42
14
+ pgdispatch endpoints enable <id> # enable and reset the circuit breaker
15
+ pgdispatch cleanup --older-than 14d
16
+
17
+ The database is taken from ``--dsn``, ``PGDISPATCH_DSN`` or ``DATABASE_URL``;
18
+ every other setting from ``PGDISPATCH_*`` environment variables.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import asyncio
25
+ import dataclasses
26
+ import json
27
+ import logging
28
+ import os
29
+ import sys
30
+ import uuid
31
+ from collections.abc import Callable, Coroutine, Sequence
32
+ from datetime import datetime, timedelta, timezone
33
+ from typing import Any, TextIO
34
+
35
+ import asyncpg
36
+
37
+ from pgdispatch._version import __version__
38
+ from pgdispatch.backends.asyncpg import AsyncpgBackend
39
+ from pgdispatch.client import Webhooks
40
+ from pgdispatch.config import WebhooksConfig, parse_duration
41
+ from pgdispatch.core.common import UNSET, OwnerFilter
42
+ from pgdispatch.errors import PgDispatchError
43
+ from pgdispatch.worker.runner import run_until_signalled
44
+
45
+ __all__ = ["main"]
46
+
47
+ Handler = Callable[[Webhooks, argparse.Namespace, TextIO], Coroutine[Any, Any, int]]
48
+
49
+
50
+ def main(argv: Sequence[str] | None = None) -> int:
51
+ parser = _build_parser()
52
+ args = parser.parse_args(argv)
53
+ if not hasattr(args, "handler"):
54
+ parser.print_help()
55
+ return 2
56
+ logging.basicConfig(
57
+ level=getattr(logging, args.log_level.upper()),
58
+ format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
59
+ )
60
+ dsn = args.dsn or os.environ.get("PGDISPATCH_DSN") or os.environ.get("DATABASE_URL")
61
+ if not dsn:
62
+ parser.error("no database given: use --dsn, PGDISPATCH_DSN or DATABASE_URL")
63
+ try:
64
+ config = WebhooksConfig(**_config_overrides(args))
65
+ except ValueError as exc:
66
+ parser.error(str(exc))
67
+ try:
68
+ return asyncio.run(_run(args.handler, dsn, config, args))
69
+ except PgDispatchError as exc:
70
+ print(f"error: {exc}", file=sys.stderr)
71
+ return 1
72
+ except asyncpg.UndefinedTableError:
73
+ print(
74
+ f"error: schema {config.db_schema!r} is not set up; run 'pgdispatch migrate' first",
75
+ file=sys.stderr,
76
+ )
77
+ return 1
78
+ except (asyncpg.PostgresError, OSError) as exc:
79
+ print(f"error: database: {exc}", file=sys.stderr)
80
+ return 1
81
+ except KeyboardInterrupt: # pragma: no cover - interactive use
82
+ return 130
83
+
84
+
85
+ async def _run(handler: Handler, dsn: str, config: WebhooksConfig, args: argparse.Namespace) -> int:
86
+ async with Webhooks(AsyncpgBackend(dsn), config) as webhooks:
87
+ return await handler(webhooks, args, sys.stdout)
88
+
89
+
90
+ # --- commands -------------------------------------------------------------------
91
+
92
+
93
+ async def _migrate(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
94
+ applied = await webhooks.migrate()
95
+ schema = webhooks.config.db_schema
96
+ if applied:
97
+ print(f"Applied migrations {', '.join(map(str, applied))} to schema {schema!r}.", file=out)
98
+ else:
99
+ print(f"Schema {schema!r} is up to date.", file=out)
100
+ return 0
101
+
102
+
103
+ async def _worker(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
104
+ await run_until_signalled(webhooks.worker(), metrics_port=args.metrics_port)
105
+ return 0
106
+
107
+
108
+ async def _status(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
109
+ stats = await webhooks.status()
110
+ if args.json:
111
+ return _print_json(dataclasses.asdict(stats), out)
112
+ rows = [
113
+ ("pending", stats.pending),
114
+ ("due now", stats.due),
115
+ ("in flight", stats.in_flight),
116
+ ("delivery lag", _format_duration(stats.oldest_due_age)),
117
+ ("failed (24h)", stats.failed_last_24h),
118
+ ("endpoints enabled", stats.endpoints_enabled),
119
+ ("endpoints disabled", stats.endpoints_disabled),
120
+ ("circuit breakers open", stats.circuits_open),
121
+ ("active workers", stats.active_workers),
122
+ ]
123
+ width = max(len(name) for name, _ in rows)
124
+ for name, value in rows:
125
+ print(f"{name:<{width}} {value}", file=out)
126
+ return 0
127
+
128
+
129
+ async def _deliveries(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
130
+ deliveries = await webhooks.deliveries.list(
131
+ endpoint_id=args.endpoint,
132
+ event_id=args.event,
133
+ status=args.status,
134
+ since=_since(args.since),
135
+ limit=args.limit,
136
+ owner_id=_owner(args.owner),
137
+ )
138
+ if args.json:
139
+ return _print_json([dataclasses.asdict(d) for d in deliveries], out)
140
+ _print_table(
141
+ out,
142
+ ["ID", "STATUS", "EVENT TYPE", "ENDPOINT", "ATTEMPTS", "LAST", "CREATED"],
143
+ [
144
+ [
145
+ d.id,
146
+ d.status.value,
147
+ d.event_type,
148
+ d.endpoint_id,
149
+ d.attempt_count,
150
+ d.last_status_code or d.last_error or "",
151
+ _format_time(d.created_at),
152
+ ]
153
+ for d in deliveries
154
+ ],
155
+ )
156
+ return 0
157
+
158
+
159
+ async def _delivery_show(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
160
+ delivery = await webhooks.deliveries.get(args.delivery_id)
161
+ attempts = await webhooks.deliveries.attempts(args.delivery_id)
162
+ if args.json:
163
+ data = dataclasses.asdict(delivery)
164
+ data["attempts"] = [dataclasses.asdict(a) for a in attempts]
165
+ return _print_json(data, out)
166
+ for name, value in dataclasses.asdict(delivery).items():
167
+ print(f"{name:<17} {_plain(value)}", file=out)
168
+ for attempt in attempts:
169
+ outcome = attempt.response_status or attempt.error
170
+ print(
171
+ f"\n#{attempt.attempt_number} {_format_time(attempt.created_at)} {outcome} "
172
+ f"{attempt.duration_ms} ms",
173
+ file=out,
174
+ )
175
+ if attempt.error and attempt.response_status:
176
+ print(f" error: {attempt.error}", file=out)
177
+ if attempt.response_body:
178
+ print(f" {attempt.response_body[:500]}", file=out)
179
+ return 0
180
+
181
+
182
+ async def _retry(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
183
+ if args.delivery_id is not None:
184
+ delivery = await webhooks.deliveries.retry(args.delivery_id)
185
+ print(f"Delivery {delivery.id} scheduled for an immediate attempt.", file=out)
186
+ return 0
187
+ count = await webhooks.deliveries.retry_all(
188
+ endpoint_id=args.endpoint, status=args.status, since=_since(args.since)
189
+ )
190
+ print(f"Scheduled {count} deliveries for retry.", file=out)
191
+ return 0
192
+
193
+
194
+ async def _replay(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
195
+ ids = await webhooks.events.replay(args.event, endpoint_id=args.endpoint)
196
+ print(f"Created {len(ids)} deliveries: {', '.join(map(str, ids)) or '-'}", file=out)
197
+ return 0
198
+
199
+
200
+ async def _endpoints_list(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
201
+ endpoints = await webhooks.endpoints.list(
202
+ owner_id=_owner(args.owner), enabled=args.enabled, limit=args.limit
203
+ )
204
+ if args.json:
205
+ return _print_json([dataclasses.asdict(e) for e in endpoints], out)
206
+ _print_table(
207
+ out,
208
+ ["ID", "OWNER", "URL", "EVENTS", "ENABLED", "CIRCUIT", "FAILURES"],
209
+ [
210
+ [
211
+ e.id,
212
+ e.owner_id or "",
213
+ e.url,
214
+ ",".join(e.event_types),
215
+ "yes" if e.is_enabled else f"no ({e.disabled_reason})",
216
+ e.circuit_state.value,
217
+ e.consecutive_failures,
218
+ ]
219
+ for e in endpoints
220
+ ],
221
+ )
222
+ return 0
223
+
224
+
225
+ async def _endpoints_enable(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
226
+ endpoint = await webhooks.endpoints.enable(args.endpoint_id)
227
+ print(f"Endpoint {endpoint.id} enabled; circuit breaker reset.", file=out)
228
+ return 0
229
+
230
+
231
+ async def _endpoints_disable(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
232
+ endpoint = await webhooks.endpoints.disable(args.endpoint_id, reason=args.reason)
233
+ print(f"Endpoint {endpoint.id} disabled.", file=out)
234
+ return 0
235
+
236
+
237
+ async def _cleanup(webhooks: Webhooks, args: argparse.Namespace, out: TextIO) -> int:
238
+ deleted = await webhooks.cleanup(older_than=args.older_than)
239
+ print(f"Deleted {deleted} events with their deliveries and attempts.", file=out)
240
+ return 0
241
+
242
+
243
+ # --- argument parsing -------------------------------------------------------------
244
+
245
+
246
+ def _global_options(*, top_level: bool) -> argparse.ArgumentParser:
247
+ # Global options are accepted before and after the command. Sub-commands
248
+ # use SUPPRESS so that an option given before the command is not
249
+ # overwritten by the sub-command's default.
250
+ def default(value: Any) -> Any:
251
+ return value if top_level else argparse.SUPPRESS
252
+
253
+ options = argparse.ArgumentParser(add_help=False)
254
+ options.add_argument(
255
+ "--dsn",
256
+ default=default(None),
257
+ help="PostgreSQL URL (default: $PGDISPATCH_DSN or $DATABASE_URL)",
258
+ )
259
+ options.add_argument(
260
+ "--schema", default=default(None), help="database schema (default: pgdispatch)"
261
+ )
262
+ options.add_argument(
263
+ "--log-level", default=default("info"), choices=["debug", "info", "warning", "error"]
264
+ )
265
+ return options
266
+
267
+
268
+ def _build_parser() -> argparse.ArgumentParser: # noqa: PLR0915 - a flat list of options
269
+ common = _global_options(top_level=False)
270
+ parser = argparse.ArgumentParser(
271
+ prog="pgdispatch",
272
+ description="Reliable webhook delivery using only PostgreSQL.",
273
+ parents=[_global_options(top_level=True)],
274
+ )
275
+ parser.add_argument("--version", action="version", version=f"pgdispatch {__version__}")
276
+ commands = parser.add_subparsers(title="commands", metavar="<command>")
277
+
278
+ def command(name: str, handler: Handler, help_text: str) -> argparse.ArgumentParser:
279
+ sub = commands.add_parser(name, help=help_text, description=help_text, parents=[common])
280
+ sub.set_defaults(handler=handler)
281
+ return sub
282
+
283
+ command("migrate", _migrate, "Create or upgrade the database schema.")
284
+
285
+ worker = command("worker", _worker, "Deliver webhooks until interrupted.")
286
+ worker.add_argument("--concurrency", type=int, help="simultaneous HTTP requests")
287
+ worker.add_argument("--batch-size", type=int, help="deliveries claimed per query")
288
+ worker.add_argument("--metrics-port", type=int, help="serve Prometheus metrics on this port")
289
+
290
+ status = command("status", _status, "Show queue size, delivery lag and open circuit breakers.")
291
+ status.add_argument("--json", action="store_true", help="machine-readable output")
292
+
293
+ deliveries = command("deliveries", _deliveries, "List recent deliveries.")
294
+ deliveries.add_argument(
295
+ "--status", choices=["pending", "in_flight", "succeeded", "failed", "cancelled"]
296
+ )
297
+ deliveries.add_argument("--endpoint", type=uuid.UUID, help="endpoint id")
298
+ deliveries.add_argument("--event", type=uuid.UUID, help="event id")
299
+ deliveries.add_argument("--owner", help="owner id")
300
+ deliveries.add_argument("--since", type=_duration, help="e.g. 30m, 1h, 7d")
301
+ deliveries.add_argument("--limit", type=int, default=50)
302
+ deliveries.add_argument("--json", action="store_true", help="machine-readable output")
303
+ show = deliveries.add_subparsers(metavar="show").add_parser(
304
+ "show", help="Show a delivery with every attempt.", parents=[common]
305
+ )
306
+ show.add_argument("delivery_id", type=int)
307
+ show.add_argument("--json", action="store_true", help="machine-readable output")
308
+ show.set_defaults(handler=_delivery_show)
309
+
310
+ retry = command(
311
+ "retry", _retry, "Retry one delivery, or every failed delivery matching filters."
312
+ )
313
+ retry.add_argument("delivery_id", type=int, nargs="?")
314
+ retry.add_argument("--endpoint", type=uuid.UUID, help="endpoint id")
315
+ retry.add_argument("--status", default="failed", choices=["failed", "cancelled", "succeeded"])
316
+ retry.add_argument("--since", type=_duration, help="only deliveries created this recently")
317
+
318
+ replay = command("replay", _replay, "Deliver an event again.")
319
+ replay.add_argument("--event", type=uuid.UUID, required=True, help="event id")
320
+ replay.add_argument("--endpoint", type=uuid.UUID, help="only to this endpoint")
321
+
322
+ endpoints = commands.add_parser("endpoints", help="Manage endpoints.", parents=[common])
323
+ endpoint_commands = endpoints.add_subparsers(
324
+ title="commands", metavar="<command>", required=True
325
+ )
326
+ listing = endpoint_commands.add_parser("list", help="List endpoints.", parents=[common])
327
+ listing.add_argument("--owner", help="owner id")
328
+ listing.add_argument("--enabled", action=argparse.BooleanOptionalAction, default=None)
329
+ listing.add_argument("--limit", type=int, default=100)
330
+ listing.add_argument("--json", action="store_true", help="machine-readable output")
331
+ listing.set_defaults(handler=_endpoints_list)
332
+ enable = endpoint_commands.add_parser(
333
+ "enable", help="Enable an endpoint and reset its circuit breaker.", parents=[common]
334
+ )
335
+ enable.add_argument("endpoint_id", type=uuid.UUID)
336
+ enable.set_defaults(handler=_endpoints_enable)
337
+ disable = endpoint_commands.add_parser("disable", help="Disable an endpoint.", parents=[common])
338
+ disable.add_argument("endpoint_id", type=uuid.UUID)
339
+ disable.add_argument("--reason")
340
+ disable.set_defaults(handler=_endpoints_disable)
341
+
342
+ cleanup = command("cleanup", _cleanup, "Delete data older than the retention period.")
343
+ cleanup.add_argument("--older-than", type=_duration, help="default: retention_days")
344
+ return parser
345
+
346
+
347
+ def _config_overrides(args: argparse.Namespace) -> dict[str, Any]:
348
+ names = {"schema": "db_schema", "concurrency": "concurrency", "batch_size": "batch_size"}
349
+ return {
350
+ setting: getattr(args, option)
351
+ for option, setting in names.items()
352
+ if getattr(args, option, None) is not None
353
+ }
354
+
355
+
356
+ def _duration(value: str) -> timedelta:
357
+ try:
358
+ return parse_duration(value)
359
+ except ValueError as exc:
360
+ raise argparse.ArgumentTypeError(str(exc)) from None
361
+
362
+
363
+ def _owner(owner: str | None) -> OwnerFilter:
364
+ # Without --owner, show every owner (None would mean "endpoints without an owner").
365
+ return UNSET if owner is None else owner
366
+
367
+
368
+ def _since(duration: timedelta | None) -> datetime | None:
369
+ return datetime.now(timezone.utc) - duration if duration else None
370
+
371
+
372
+ # --- output -------------------------------------------------------------------------
373
+
374
+
375
+ def _print_table(out: TextIO, headers: list[str], rows: list[list[Any]]) -> None:
376
+ if not rows:
377
+ print("(none)", file=out)
378
+ return
379
+ cells = [headers, *[[_plain(value) for value in row] for row in rows]]
380
+ widths = [max(len(row[column]) for row in cells) for column in range(len(headers))]
381
+ for row in cells:
382
+ print(
383
+ " ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True)).rstrip(),
384
+ file=out,
385
+ )
386
+
387
+
388
+ def _print_json(data: Any, out: TextIO) -> int:
389
+ json.dump(data, out, indent=2, default=_plain)
390
+ out.write("\n")
391
+ return 0
392
+
393
+
394
+ def _plain(value: Any) -> str:
395
+ if isinstance(value, datetime):
396
+ return _format_time(value)
397
+ if isinstance(value, timedelta):
398
+ return _format_duration(value)
399
+ if hasattr(value, "value"): # enums
400
+ return str(value.value)
401
+ return "" if value is None else str(value)
402
+
403
+
404
+ def _format_time(moment: datetime) -> str:
405
+ return moment.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
406
+
407
+
408
+ def _format_duration(duration: timedelta) -> str:
409
+ seconds = int(duration.total_seconds())
410
+ if seconds < 60:
411
+ return f"{seconds}s"
412
+ minutes, seconds = divmod(seconds, 60)
413
+ if minutes < 60:
414
+ return f"{minutes}m{seconds:02d}s"
415
+ hours, minutes = divmod(minutes, 60)
416
+ return f"{hours}h{minutes:02d}m"
417
+
418
+
419
+ if __name__ == "__main__": # pragma: no cover
420
+ sys.exit(main())