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.
- pgdispatch/__init__.py +69 -0
- pgdispatch/__main__.py +5 -0
- pgdispatch/_version.py +1 -0
- pgdispatch/backends/__init__.py +6 -0
- pgdispatch/backends/asyncpg.py +103 -0
- pgdispatch/backends/base.py +142 -0
- pgdispatch/cli.py +420 -0
- pgdispatch/client.py +490 -0
- pgdispatch/config.py +223 -0
- pgdispatch/core/__init__.py +1 -0
- pgdispatch/core/circuit.py +94 -0
- pgdispatch/core/common.py +87 -0
- pgdispatch/core/deliveries.py +216 -0
- pgdispatch/core/endpoints.py +338 -0
- pgdispatch/core/events.py +65 -0
- pgdispatch/core/maintenance.py +121 -0
- pgdispatch/core/matching.py +76 -0
- pgdispatch/core/payload.py +108 -0
- pgdispatch/core/publisher.py +232 -0
- pgdispatch/core/retry.py +90 -0
- pgdispatch/core/secrets.py +138 -0
- pgdispatch/core/signing.py +141 -0
- pgdispatch/django/__init__.py +37 -0
- pgdispatch/django/apps.py +10 -0
- pgdispatch/django/client.py +402 -0
- pgdispatch/django/management/__init__.py +0 -0
- pgdispatch/django/management/commands/__init__.py +0 -0
- pgdispatch/django/management/commands/pgdispatch_worker.py +32 -0
- pgdispatch/django/migrations/0001_initial.py +30 -0
- pgdispatch/django/migrations/__init__.py +0 -0
- pgdispatch/errors.py +43 -0
- pgdispatch/fastapi/__init__.py +23 -0
- pgdispatch/fastapi/router.py +243 -0
- pgdispatch/hooks.py +80 -0
- pgdispatch/metrics.py +80 -0
- pgdispatch/migrations/0001_initial.sql +104 -0
- pgdispatch/migrations/__init__.py +93 -0
- pgdispatch/models.py +208 -0
- pgdispatch/py.typed +0 -0
- pgdispatch/security/__init__.py +1 -0
- pgdispatch/security/ssrf.py +141 -0
- pgdispatch/sqlalchemy.py +130 -0
- pgdispatch/worker/__init__.py +5 -0
- pgdispatch/worker/fetcher.py +183 -0
- pgdispatch/worker/ratelimit.py +47 -0
- pgdispatch/worker/recorder.py +435 -0
- pgdispatch/worker/runner.py +36 -0
- pgdispatch/worker/sender.py +160 -0
- pgdispatch/worker/worker.py +557 -0
- pgdispatch-0.1.0.dist-info/METADATA +329 -0
- pgdispatch-0.1.0.dist-info/RECORD +54 -0
- pgdispatch-0.1.0.dist-info/WHEEL +4 -0
- pgdispatch-0.1.0.dist-info/entry_points.txt +2 -0
- 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())
|