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/client.py ADDED
@@ -0,0 +1,490 @@
1
+ """The public, asynchronous entry point: :class:`Webhooks`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import builtins
7
+ import contextlib
8
+ import logging
9
+ import uuid
10
+ from collections.abc import AsyncIterator, Callable, Iterable, Mapping
11
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
12
+ from datetime import datetime, timedelta
13
+ from typing import Any, TypeVar
14
+
15
+ from prometheus_client import CollectorRegistry
16
+
17
+ from pgdispatch.backends.base import Backend, Operation, run_async
18
+ from pgdispatch.config import WebhooksConfig
19
+ from pgdispatch.core import deliveries as delivery_ops
20
+ from pgdispatch.core import endpoints as endpoint_ops
21
+ from pgdispatch.core import events as event_ops
22
+ from pgdispatch.core import publisher
23
+ from pgdispatch.core.common import UNSET, OwnerFilter, Unset
24
+ from pgdispatch.core.maintenance import QueueStats, delete_expired_events, queue_stats
25
+ from pgdispatch.core.secrets import SecretCipher, SecretStore, fernet_cipher
26
+ from pgdispatch.hooks import Hooks
27
+ from pgdispatch.metrics import get_metrics
28
+ from pgdispatch.migrations import migrate
29
+ from pgdispatch.models import Attempt, Delivery, DeliveryStatus, Endpoint, Event, PublishResult
30
+ from pgdispatch.worker.worker import Worker
31
+
32
+ __all__ = ["DeliveriesAPI", "EndpointsAPI", "EventsAPI", "Webhooks"]
33
+
34
+ logger = logging.getLogger("pgdispatch")
35
+
36
+ T = TypeVar("T")
37
+
38
+
39
+ class Webhooks:
40
+ """Publish events and manage endpoints and deliveries.
41
+
42
+ ::
43
+
44
+ from pgdispatch import Webhooks
45
+ from pgdispatch.sqlalchemy import SQLAlchemyBackend
46
+
47
+ webhooks = Webhooks(SQLAlchemyBackend(engine))
48
+
49
+ async with session.begin():
50
+ session.add(payment)
51
+ await webhooks.publish(session, "payment.paid", {"id": payment.id})
52
+
53
+ Every method that takes ``owner_id`` uses it to scope the operation to one
54
+ tenant: objects of other owners are reported as not found. Leave it out in
55
+ trusted, internal code to operate on all owners.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ backend: Backend,
61
+ config: WebhooksConfig | None = None,
62
+ *,
63
+ hooks: Hooks | None = None,
64
+ secret_cipher: SecretCipher | None = None,
65
+ metrics_registry: CollectorRegistry | None = None,
66
+ ) -> None:
67
+ self.backend = backend
68
+ self.config = config or WebhooksConfig()
69
+ self.hooks = hooks or Hooks()
70
+ if secret_cipher is None and self.config.secret_encryption_keys:
71
+ keys = [key.get_secret_value() for key in self.config.secret_encryption_keys]
72
+ secret_cipher = fernet_cipher(keys)
73
+ self._secrets = SecretStore(secret_cipher)
74
+ self._metrics = get_metrics(metrics_registry)
75
+ self._url_policy = endpoint_ops.UrlPolicy(
76
+ allow_http=self.config.allow_http, allow_private_ips=self.config.allow_private_ips
77
+ )
78
+ self.endpoints = EndpointsAPI(self)
79
+ self.deliveries = DeliveriesAPI(self)
80
+ self.events = EventsAPI(self)
81
+
82
+ async def publish(
83
+ self,
84
+ connection: Any,
85
+ event_type: str,
86
+ payload: Any,
87
+ *,
88
+ owner_id: str | None = None,
89
+ ordering_key: str | None = None,
90
+ idempotency_key: str | None = None,
91
+ ) -> PublishResult:
92
+ """Record an event in the caller's transaction and schedule its deliveries.
93
+
94
+ ``connection`` is whatever your backend works with: an SQLAlchemy
95
+ ``AsyncSession``/``AsyncConnection`` or an asyncpg connection. Run this
96
+ inside the transaction that changes your business data: if it rolls
97
+ back, the event never existed. Pass ``None`` to publish in a
98
+ transaction of its own.
99
+
100
+ :param event_type: Dot-separated type such as ``payment.paid``.
101
+ :param payload: Any JSON-serializable value; sent as ``data``.
102
+ :param owner_id: Deliver only to endpoints of this owner (tenant).
103
+ :param ordering_key: Deliveries with the same key to the same endpoint
104
+ are sent strictly one after another, in publish order.
105
+ :param idempotency_key: Publishing the same key twice creates the
106
+ event only once; the duplicate returns the original event id.
107
+ """
108
+ operation = publisher.publish(
109
+ event_type=event_type,
110
+ payload=payload,
111
+ owner_id=owner_id,
112
+ ordering_key=ordering_key,
113
+ idempotency_key=idempotency_key,
114
+ channel=self.config.notify_channel,
115
+ max_payload_bytes=self.config.max_payload_bytes,
116
+ initial_delay=self.config.retry_schedule[0],
117
+ )
118
+ if connection is None:
119
+ result = await self._run(operation)
120
+ else:
121
+ executor = self.backend.executor(connection)
122
+ result = await run_async(operation, executor, schema=self.config.db_schema)
123
+ if not result.duplicate:
124
+ self._metrics.events_published.labels(event_type).inc()
125
+ return result
126
+
127
+ async def migrate(self) -> list[int]:
128
+ """Create or upgrade the database schema; return the versions applied."""
129
+ return await self._run(migrate())
130
+
131
+ async def status(self) -> QueueStats:
132
+ """Queue size, delivery lag, open circuits and live workers."""
133
+ ttl = self.config.heartbeat_interval * 3
134
+ return await self._run(queue_stats(worker_ttl=ttl))
135
+
136
+ async def cleanup(self, older_than: timedelta | None = None) -> int:
137
+ """Delete events (with deliveries and attempts) older than the retention period."""
138
+ age = older_than or timedelta(days=self.config.retention_days)
139
+ total = 0
140
+ while True:
141
+ deleted = await self._run(
142
+ delete_expired_events(older_than=age, batch_size=self.config.cleanup_batch_size)
143
+ )
144
+ total += deleted
145
+ if deleted < self.config.cleanup_batch_size:
146
+ return total
147
+
148
+ def worker(self, **overrides: Any) -> Worker:
149
+ """Create a :class:`Worker` sharing this instance's configuration and hooks.
150
+
151
+ Keyword arguments override configuration values, e.g. ``concurrency=100``.
152
+ """
153
+ config = (
154
+ WebhooksConfig(**{**self.config.model_dump(), **overrides})
155
+ if overrides
156
+ else self.config
157
+ )
158
+ return Worker(
159
+ self.backend.create_worker_pool,
160
+ config,
161
+ hooks=self.hooks,
162
+ metrics=self._metrics,
163
+ secret_store=self._secrets,
164
+ )
165
+
166
+ @asynccontextmanager
167
+ async def running_worker(self, **overrides: Any) -> AsyncIterator[Worker]:
168
+ """Run a worker in the background for the duration of the ``async with`` block."""
169
+ worker = self.worker(**overrides)
170
+ task = asyncio.create_task(worker.run(), name="pgdispatch-worker")
171
+ task.add_done_callback(_log_worker_crash)
172
+ try:
173
+ yield worker
174
+ finally:
175
+ worker.stop()
176
+ with contextlib.suppress(Exception):
177
+ await task
178
+
179
+ def lifespan(
180
+ self, *, run_worker: bool = True, migrate: bool = False
181
+ ) -> Callable[[Any], AbstractAsyncContextManager[None]]:
182
+ """An ASGI lifespan handler, e.g. ``FastAPI(lifespan=webhooks.lifespan())``.
183
+
184
+ :param run_worker: Deliver webhooks from inside the application process.
185
+ Fine for small deployments; run ``pgdispatch worker`` separately to
186
+ scale delivery independently.
187
+ :param migrate: Apply pending migrations on start-up.
188
+ """
189
+
190
+ @asynccontextmanager
191
+ async def handler(app: Any) -> AsyncIterator[None]:
192
+ del app
193
+ try:
194
+ if migrate:
195
+ await self.migrate()
196
+ if run_worker:
197
+ async with self.running_worker():
198
+ yield
199
+ else:
200
+ yield
201
+ finally:
202
+ await self.close()
203
+
204
+ return handler
205
+
206
+ async def close(self) -> None:
207
+ """Release connections owned by the backend."""
208
+ await self.backend.close()
209
+
210
+ async def __aenter__(self) -> Webhooks:
211
+ return self
212
+
213
+ async def __aexit__(self, *exc_info: object) -> None:
214
+ await self.close()
215
+
216
+ async def _run(self, operation: Operation[T]) -> T:
217
+ async with self.backend.transaction() as executor:
218
+ return await run_async(operation, executor, schema=self.config.db_schema)
219
+
220
+
221
+ class EndpointsAPI:
222
+ """``webhooks.endpoints``: register and manage receiver URLs."""
223
+
224
+ def __init__(self, webhooks: Webhooks) -> None:
225
+ self._webhooks = webhooks
226
+
227
+ async def create(
228
+ self,
229
+ url: str,
230
+ *,
231
+ event_types: Iterable[str] = ("*",),
232
+ owner_id: str | None = None,
233
+ description: str | None = None,
234
+ headers: Mapping[str, str] | None = None,
235
+ rate_limit_rps: float | None = None,
236
+ secret: str | None = None,
237
+ enabled: bool = True,
238
+ ) -> Endpoint:
239
+ """Register an endpoint. The returned ``endpoint.secret`` is the signing secret.
240
+
241
+ :param event_types: Subscription patterns: ``"*"``, ``"payment.paid"``, ``"payment.*"``.
242
+ :param headers: Extra HTTP headers sent with every request (e.g. an API key).
243
+ :param rate_limit_rps: Maximum requests per second to this endpoint.
244
+ :param secret: Use this ``whsec_`` secret instead of generating one.
245
+ """
246
+ w = self._webhooks
247
+ return await w._run(
248
+ endpoint_ops.create_endpoint(
249
+ url=url,
250
+ event_types=event_types,
251
+ owner_id=owner_id,
252
+ description=description,
253
+ headers=headers,
254
+ rate_limit_rps=rate_limit_rps,
255
+ secret=secret,
256
+ enabled=enabled,
257
+ url_policy=w._url_policy,
258
+ secret_store=w._secrets,
259
+ )
260
+ )
261
+
262
+ async def get(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> Endpoint:
263
+ return await self._webhooks._run(endpoint_ops.get_endpoint(endpoint_id, owner_id=owner_id))
264
+
265
+ async def list(
266
+ self,
267
+ *,
268
+ owner_id: OwnerFilter = UNSET,
269
+ enabled: bool | None = None,
270
+ limit: int = 100,
271
+ after: uuid.UUID | None = None,
272
+ ) -> builtins.list[Endpoint]:
273
+ """Endpoints ordered by id; pass the last id as ``after`` for the next page."""
274
+ return await self._webhooks._run(
275
+ endpoint_ops.list_endpoints(
276
+ owner_id=owner_id, enabled=enabled, limit=limit, after=after
277
+ )
278
+ )
279
+
280
+ async def update(
281
+ self,
282
+ endpoint_id: uuid.UUID,
283
+ *,
284
+ url: str | Unset = UNSET,
285
+ event_types: Iterable[str] | Unset = UNSET,
286
+ description: str | Unset | None = UNSET,
287
+ headers: Mapping[str, str] | Unset = UNSET,
288
+ rate_limit_rps: float | Unset | None = UNSET,
289
+ owner_id: OwnerFilter = UNSET,
290
+ ) -> Endpoint:
291
+ """Change the given fields; fields that are not passed stay as they are."""
292
+ w = self._webhooks
293
+ return await w._run(
294
+ endpoint_ops.update_endpoint(
295
+ endpoint_id,
296
+ owner_id=owner_id,
297
+ url=url,
298
+ event_types=event_types,
299
+ description=description,
300
+ headers=headers,
301
+ rate_limit_rps=rate_limit_rps,
302
+ url_policy=w._url_policy,
303
+ )
304
+ )
305
+
306
+ async def enable(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> Endpoint:
307
+ """Enable the endpoint and reset its circuit breaker."""
308
+ return await self._webhooks._run(
309
+ endpoint_ops.enable_endpoint(endpoint_id, owner_id=owner_id)
310
+ )
311
+
312
+ async def disable(
313
+ self, endpoint_id: uuid.UUID, *, reason: str | None = None, owner_id: OwnerFilter = UNSET
314
+ ) -> Endpoint:
315
+ """Pause deliveries. Pending deliveries are kept and resume on :meth:`enable`."""
316
+ return await self._webhooks._run(
317
+ endpoint_ops.disable_endpoint(endpoint_id, reason=reason, owner_id=owner_id)
318
+ )
319
+
320
+ async def delete(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> None:
321
+ """Delete the endpoint with its deliveries and attempt log."""
322
+ await self._webhooks._run(endpoint_ops.delete_endpoint(endpoint_id, owner_id=owner_id))
323
+
324
+ async def rotate_secret(
325
+ self,
326
+ endpoint_id: uuid.UUID,
327
+ *,
328
+ grace_period: timedelta = timedelta(hours=24),
329
+ secret: str | None = None,
330
+ owner_id: OwnerFilter = UNSET,
331
+ ) -> Endpoint:
332
+ """Issue a new signing secret; the old one stays valid for ``grace_period``.
333
+
334
+ During the grace period requests carry signatures for both secrets, so
335
+ the receiver can switch whenever it is ready.
336
+ """
337
+ w = self._webhooks
338
+ return await w._run(
339
+ endpoint_ops.rotate_secret(
340
+ endpoint_id,
341
+ grace_period=grace_period,
342
+ secret=secret,
343
+ owner_id=owner_id,
344
+ secret_store=w._secrets,
345
+ )
346
+ )
347
+
348
+ async def get_secret(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> str:
349
+ """Return the current signing secret."""
350
+ w = self._webhooks
351
+ return await w._run(
352
+ endpoint_ops.get_secret(endpoint_id, owner_id=owner_id, secret_store=w._secrets)
353
+ )
354
+
355
+ async def send_test(
356
+ self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET
357
+ ) -> PublishResult:
358
+ """Send a ``webhook.test`` event to this endpoint only."""
359
+ w = self._webhooks
360
+ return await w._run(
361
+ publisher.send_test(endpoint_id, owner_id=owner_id, channel=w.config.notify_channel)
362
+ )
363
+
364
+
365
+ class DeliveriesAPI:
366
+ """``webhooks.deliveries``: inspect and retry deliveries."""
367
+
368
+ def __init__(self, webhooks: Webhooks) -> None:
369
+ self._webhooks = webhooks
370
+
371
+ async def list(
372
+ self,
373
+ *,
374
+ endpoint_id: uuid.UUID | None = None,
375
+ event_id: uuid.UUID | None = None,
376
+ status: DeliveryStatus | str | None = None,
377
+ since: datetime | None = None,
378
+ before: int | None = None,
379
+ limit: int = 50,
380
+ owner_id: OwnerFilter = UNSET,
381
+ ) -> builtins.list[Delivery]:
382
+ """Newest first; pass the last id as ``before`` for the next page."""
383
+ return await self._webhooks._run(
384
+ delivery_ops.list_deliveries(
385
+ endpoint_id=endpoint_id,
386
+ event_id=event_id,
387
+ status=status,
388
+ since=since,
389
+ before=before,
390
+ limit=limit,
391
+ owner_id=owner_id,
392
+ )
393
+ )
394
+
395
+ async def get(self, delivery_id: int, *, owner_id: OwnerFilter = UNSET) -> Delivery:
396
+ return await self._webhooks._run(delivery_ops.get_delivery(delivery_id, owner_id=owner_id))
397
+
398
+ async def attempts(
399
+ self, delivery_id: int, *, owner_id: OwnerFilter = UNSET
400
+ ) -> builtins.list[Attempt]:
401
+ """Every HTTP request made for the delivery, with responses; oldest first."""
402
+ return await self._webhooks._run(delivery_ops.list_attempts(delivery_id, owner_id=owner_id))
403
+
404
+ async def retry(self, delivery_id: int, *, owner_id: OwnerFilter = UNSET) -> Delivery:
405
+ """Send again now, with a fresh retry schedule."""
406
+ w = self._webhooks
407
+ return await w._run(
408
+ delivery_ops.retry_delivery(
409
+ delivery_id, owner_id=owner_id, channel=w.config.notify_channel
410
+ )
411
+ )
412
+
413
+ async def retry_all(
414
+ self,
415
+ *,
416
+ endpoint_id: uuid.UUID | None = None,
417
+ status: DeliveryStatus | str = DeliveryStatus.FAILED,
418
+ since: datetime | None = None,
419
+ owner_id: OwnerFilter = UNSET,
420
+ ) -> int:
421
+ """Retry every matching delivery (failed ones by default); return how many."""
422
+ w = self._webhooks
423
+ return await w._run(
424
+ delivery_ops.retry_deliveries(
425
+ endpoint_id=endpoint_id,
426
+ status=status,
427
+ since=since,
428
+ owner_id=owner_id,
429
+ channel=w.config.notify_channel,
430
+ )
431
+ )
432
+
433
+ async def cancel(self, delivery_id: int, *, owner_id: OwnerFilter = UNSET) -> Delivery:
434
+ """Abandon a pending delivery."""
435
+ return await self._webhooks._run(
436
+ delivery_ops.cancel_delivery(delivery_id, owner_id=owner_id)
437
+ )
438
+
439
+
440
+ class EventsAPI:
441
+ """``webhooks.events``: look up and replay events."""
442
+
443
+ def __init__(self, webhooks: Webhooks) -> None:
444
+ self._webhooks = webhooks
445
+
446
+ async def get(self, event_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> Event:
447
+ return await self._webhooks._run(event_ops.get_event(event_id, owner_id=owner_id))
448
+
449
+ async def list(
450
+ self,
451
+ *,
452
+ owner_id: OwnerFilter = UNSET,
453
+ event_type: str | None = None,
454
+ since: datetime | None = None,
455
+ before: uuid.UUID | None = None,
456
+ limit: int = 50,
457
+ ) -> builtins.list[Event]:
458
+ """Newest first; pass the last id as ``before`` for the next page."""
459
+ return await self._webhooks._run(
460
+ event_ops.list_events(
461
+ owner_id=owner_id, event_type=event_type, since=since, before=before, limit=limit
462
+ )
463
+ )
464
+
465
+ async def replay(
466
+ self,
467
+ event_id: uuid.UUID,
468
+ *,
469
+ endpoint_id: uuid.UUID | None = None,
470
+ owner_id: OwnerFilter = UNSET,
471
+ ) -> builtins.list[int]:
472
+ """Deliver an event again; return the new delivery ids.
473
+
474
+ With ``endpoint_id``, only to that endpoint (it must belong to the
475
+ event's owner). Without it, to every endpoint subscribed *now*.
476
+ """
477
+ w = self._webhooks
478
+ return await w._run(
479
+ publisher.replay(
480
+ event_id,
481
+ endpoint_id=endpoint_id,
482
+ owner_id=owner_id,
483
+ channel=w.config.notify_channel,
484
+ )
485
+ )
486
+
487
+
488
+ def _log_worker_crash(task: asyncio.Task[None]) -> None:
489
+ if not task.cancelled() and task.exception() is not None:
490
+ logger.error("pgdispatch worker crashed", exc_info=task.exception())