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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Event payload serialization and the body format sent to receivers.
|
|
2
|
+
|
|
3
|
+
Receivers get the envelope recommended by Standard Webhooks::
|
|
4
|
+
|
|
5
|
+
{"type": "payment.paid", "timestamp": "2026-11-01T10:15:00.123Z", "data": {...}}
|
|
6
|
+
|
|
7
|
+
The body is produced once per attempt and the exact same bytes are signed and
|
|
8
|
+
sent.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
import enum
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from datetime import date, datetime, timezone
|
|
20
|
+
from decimal import Decimal
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from pgdispatch.errors import PayloadTooLargeError, ValidationError
|
|
24
|
+
|
|
25
|
+
__all__ = ["build_body", "dumps", "serialize_payload", "uuid7"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def dumps(value: Any) -> str:
|
|
29
|
+
"""Serialize to compact JSON, understanding common Python types.
|
|
30
|
+
|
|
31
|
+
Supported beyond plain JSON: ``datetime``/``date`` (ISO 8601), ``UUID``,
|
|
32
|
+
``Decimal`` (as a string, to keep precision), ``Enum``, sets and tuples,
|
|
33
|
+
dataclasses and Pydantic models.
|
|
34
|
+
"""
|
|
35
|
+
return json.dumps(
|
|
36
|
+
value,
|
|
37
|
+
default=_default,
|
|
38
|
+
ensure_ascii=False,
|
|
39
|
+
allow_nan=False,
|
|
40
|
+
separators=(",", ":"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def serialize_payload(payload: Any, *, max_bytes: int) -> str:
|
|
45
|
+
"""Serialize an event payload, enforcing the size limit and PostgreSQL's ``jsonb`` rules."""
|
|
46
|
+
try:
|
|
47
|
+
text = dumps(payload)
|
|
48
|
+
except (TypeError, ValueError) as exc:
|
|
49
|
+
msg = f"payload is not JSON-serializable: {exc}"
|
|
50
|
+
raise ValidationError(msg) from exc
|
|
51
|
+
if "\\u0000" in text:
|
|
52
|
+
msg = "payload must not contain NUL characters (\\u0000); PostgreSQL jsonb rejects them"
|
|
53
|
+
raise ValidationError(msg)
|
|
54
|
+
size = len(text.encode())
|
|
55
|
+
if size > max_bytes:
|
|
56
|
+
msg = f"payload is {size} bytes, the limit is {max_bytes} (max_payload_bytes)"
|
|
57
|
+
raise PayloadTooLargeError(msg)
|
|
58
|
+
return text
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_body(event_type: str, created_at: datetime, payload_json: str) -> bytes:
|
|
62
|
+
"""Wrap a stored payload in the Standard Webhooks envelope.
|
|
63
|
+
|
|
64
|
+
``payload_json`` is the payload exactly as PostgreSQL returned it, so it is
|
|
65
|
+
embedded verbatim instead of being decoded and encoded again.
|
|
66
|
+
"""
|
|
67
|
+
timestamp = created_at.astimezone(timezone.utc).isoformat(timespec="milliseconds")
|
|
68
|
+
timestamp = timestamp.replace("+00:00", "Z")
|
|
69
|
+
head = dumps({"type": event_type, "timestamp": timestamp})
|
|
70
|
+
return f'{head[:-1]},"data":{payload_json}}}'.encode()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def uuid7() -> uuid.UUID:
|
|
74
|
+
"""Generate a time-ordered UUID (RFC 9562, version 7).
|
|
75
|
+
|
|
76
|
+
Time ordering keeps the events primary-key index compact and lets ids be
|
|
77
|
+
sorted chronologically.
|
|
78
|
+
"""
|
|
79
|
+
timestamp_ms = time.time_ns() // 1_000_000
|
|
80
|
+
rand = int.from_bytes(os.urandom(10), "big")
|
|
81
|
+
value = (timestamp_ms & 0xFFFF_FFFF_FFFF) << 80
|
|
82
|
+
value |= 0x7 << 76 # version
|
|
83
|
+
value |= (rand >> 68) << 64 # 12 random bits
|
|
84
|
+
value |= 0b10 << 62 # variant
|
|
85
|
+
value |= rand & 0x3FFF_FFFF_FFFF_FFFF # 62 random bits
|
|
86
|
+
return uuid.UUID(int=value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _default(value: Any) -> Any: # noqa: PLR0911 - one return per supported type
|
|
90
|
+
if isinstance(value, datetime):
|
|
91
|
+
return value.isoformat()
|
|
92
|
+
if isinstance(value, date):
|
|
93
|
+
return value.isoformat()
|
|
94
|
+
if isinstance(value, uuid.UUID):
|
|
95
|
+
return str(value)
|
|
96
|
+
if isinstance(value, Decimal):
|
|
97
|
+
return str(value)
|
|
98
|
+
if isinstance(value, enum.Enum):
|
|
99
|
+
return value.value
|
|
100
|
+
if isinstance(value, (set, frozenset, tuple)):
|
|
101
|
+
return list(value)
|
|
102
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
103
|
+
return dataclasses.asdict(value)
|
|
104
|
+
model_dump = getattr(value, "model_dump", None)
|
|
105
|
+
if callable(model_dump):
|
|
106
|
+
return model_dump(mode="json")
|
|
107
|
+
msg = f"Object of type {type(value).__name__} is not JSON serializable"
|
|
108
|
+
raise TypeError(msg)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Publishing events: the transactional outbox.
|
|
2
|
+
|
|
3
|
+
:func:`publish` runs inside the caller's transaction and, in one statement,
|
|
4
|
+
|
|
5
|
+
1. inserts the event (skipping it if the idempotency key is already known),
|
|
6
|
+
2. fans it out into one ``deliveries`` row per subscribed endpoint, and
|
|
7
|
+
3. queues a ``NOTIFY`` for the workers.
|
|
8
|
+
|
|
9
|
+
PostgreSQL delivers notifications only when the transaction commits, so a
|
|
10
|
+
rolled-back transaction leaves no event, no deliveries and no wake-up.
|
|
11
|
+
Subscriptions are resolved at publish time: an endpoint created later does not
|
|
12
|
+
receive earlier events (use replay for that).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import uuid
|
|
18
|
+
from datetime import timedelta
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from pgdispatch.backends.base import Operation, Query
|
|
22
|
+
from pgdispatch.core.common import (
|
|
23
|
+
OwnerFilter,
|
|
24
|
+
first_or_not_found,
|
|
25
|
+
owner_condition,
|
|
26
|
+
validate_key,
|
|
27
|
+
)
|
|
28
|
+
from pgdispatch.core.matching import candidate_patterns, validate_event_type
|
|
29
|
+
from pgdispatch.core.payload import serialize_payload, uuid7
|
|
30
|
+
from pgdispatch.errors import ConflictError, NotFoundError
|
|
31
|
+
from pgdispatch.models import PublishResult, to_uuid
|
|
32
|
+
|
|
33
|
+
__all__ = ["TEST_EVENT_TYPE", "publish", "replay", "send_test"]
|
|
34
|
+
|
|
35
|
+
TEST_EVENT_TYPE = "webhook.test"
|
|
36
|
+
|
|
37
|
+
_FANOUT_TARGETS = """
|
|
38
|
+
FROM {schema}.endpoints AS e
|
|
39
|
+
WHERE e.is_enabled
|
|
40
|
+
AND {owner_match}
|
|
41
|
+
AND e.event_types && CAST(:patterns AS text[])
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_PUBLISH = """
|
|
45
|
+
WITH new_event AS (
|
|
46
|
+
INSERT INTO {schema}.events (id, event_type, owner_id, payload, idempotency_key)
|
|
47
|
+
VALUES (CAST(:event_id AS uuid), CAST(:event_type AS text), CAST(:event_owner AS text),
|
|
48
|
+
CAST(:payload AS jsonb), CAST(:idempotency_key AS text))
|
|
49
|
+
ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
|
|
50
|
+
RETURNING id
|
|
51
|
+
), fanout AS (
|
|
52
|
+
INSERT INTO {schema}.deliveries (event_id, endpoint_id, ordering_key, next_attempt_at)
|
|
53
|
+
SELECT new_event.id, e.id, CAST(:ordering_key AS text),
|
|
54
|
+
now() + CAST(:initial_delay AS double precision) * interval '1 second'
|
|
55
|
+
FROM new_event, ({targets}) AS e
|
|
56
|
+
RETURNING 1
|
|
57
|
+
), wake AS (
|
|
58
|
+
SELECT pg_notify(CAST(:channel AS text), '') FROM fanout LIMIT 1
|
|
59
|
+
)
|
|
60
|
+
SELECT (SELECT count(*) FROM new_event) AS inserted,
|
|
61
|
+
(SELECT count(*) FROM fanout) AS deliveries,
|
|
62
|
+
(SELECT count(*) FROM wake) AS notified
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def publish(
|
|
67
|
+
*,
|
|
68
|
+
event_type: str,
|
|
69
|
+
payload: Any,
|
|
70
|
+
owner_id: str | None,
|
|
71
|
+
ordering_key: str | None,
|
|
72
|
+
idempotency_key: str | None,
|
|
73
|
+
channel: str,
|
|
74
|
+
max_payload_bytes: int,
|
|
75
|
+
initial_delay: timedelta = timedelta(0),
|
|
76
|
+
) -> Operation[PublishResult]:
|
|
77
|
+
validate_event_type(event_type)
|
|
78
|
+
params: dict[str, Any] = {
|
|
79
|
+
"event_id": str(uuid7()),
|
|
80
|
+
"event_type": event_type,
|
|
81
|
+
"event_owner": validate_key(owner_id, "owner_id") if owner_id is not None else None,
|
|
82
|
+
"payload": serialize_payload(payload, max_bytes=max_payload_bytes),
|
|
83
|
+
"ordering_key": validate_key(ordering_key, "ordering_key") if ordering_key else None,
|
|
84
|
+
"idempotency_key": (
|
|
85
|
+
validate_key(idempotency_key, "idempotency_key") if idempotency_key else None
|
|
86
|
+
),
|
|
87
|
+
"patterns": candidate_patterns(event_type),
|
|
88
|
+
"channel": channel,
|
|
89
|
+
"initial_delay": initial_delay.total_seconds(),
|
|
90
|
+
}
|
|
91
|
+
sql = _PUBLISH.replace("{targets}", "SELECT e.id " + _fanout_targets(owner_id, params))
|
|
92
|
+
rows = yield Query(sql, params)
|
|
93
|
+
row = rows[0]
|
|
94
|
+
if row["inserted"]:
|
|
95
|
+
return PublishResult(event_id=uuid.UUID(params["event_id"]), deliveries=row["deliveries"])
|
|
96
|
+
|
|
97
|
+
# The idempotency key exists. In READ COMMITTED this statement sees rows
|
|
98
|
+
# committed after the previous one started, so a concurrent duplicate is found.
|
|
99
|
+
rows = yield Query(
|
|
100
|
+
"SELECT id FROM {schema}.events WHERE idempotency_key = CAST(:key AS text)",
|
|
101
|
+
{"key": idempotency_key},
|
|
102
|
+
)
|
|
103
|
+
if not rows: # pragma: no cover - only under REPEATABLE READ with a concurrent publisher
|
|
104
|
+
msg = f"an event with idempotency key {idempotency_key!r} is being published concurrently"
|
|
105
|
+
raise ConflictError(msg)
|
|
106
|
+
return PublishResult(event_id=to_uuid(rows[0]["id"]), deliveries=0, duplicate=True)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def replay(
|
|
110
|
+
event_id: uuid.UUID,
|
|
111
|
+
*,
|
|
112
|
+
endpoint_id: uuid.UUID | None,
|
|
113
|
+
owner_id: OwnerFilter,
|
|
114
|
+
channel: str,
|
|
115
|
+
) -> Operation[list[int]]:
|
|
116
|
+
"""Deliver an existing event again; return the ids of the new deliveries.
|
|
117
|
+
|
|
118
|
+
Without ``endpoint_id`` the event goes to every enabled endpoint that is
|
|
119
|
+
subscribed *now*, which is how you backfill a newly created endpoint.
|
|
120
|
+
Replays bypass ``ordering_key`` so they never wait behind live traffic.
|
|
121
|
+
"""
|
|
122
|
+
params: dict[str, Any] = {"event_id": str(event_id), "channel": channel}
|
|
123
|
+
owner_sql = owner_condition("ev.owner_id", owner_id, params)
|
|
124
|
+
rows = yield Query(
|
|
125
|
+
f"""
|
|
126
|
+
SELECT ev.id, ev.event_type, ev.owner_id FROM {{schema}}.events AS ev
|
|
127
|
+
WHERE ev.id = CAST(:event_id AS uuid) AND {owner_sql}
|
|
128
|
+
""",
|
|
129
|
+
params,
|
|
130
|
+
)
|
|
131
|
+
event = first_or_not_found(rows, "event", event_id)
|
|
132
|
+
|
|
133
|
+
insert_params: dict[str, Any] = {"event_id": str(event_id), "channel": channel}
|
|
134
|
+
if endpoint_id is not None:
|
|
135
|
+
# Only endpoints of the event's owner may receive it: never leak across tenants.
|
|
136
|
+
insert_params["endpoint_id"] = str(endpoint_id)
|
|
137
|
+
same_owner = _owner_match(event["owner_id"], insert_params, column="e.owner_id")
|
|
138
|
+
targets = f"""
|
|
139
|
+
SELECT e.id FROM {{schema}}.endpoints AS e
|
|
140
|
+
WHERE e.id = CAST(:endpoint_id AS uuid) AND {same_owner}
|
|
141
|
+
"""
|
|
142
|
+
else:
|
|
143
|
+
insert_params["patterns"] = candidate_patterns(event["event_type"])
|
|
144
|
+
targets = "SELECT e.id " + _fanout_targets(event["owner_id"], insert_params)
|
|
145
|
+
|
|
146
|
+
rows = yield Query(
|
|
147
|
+
f"""
|
|
148
|
+
WITH created AS (
|
|
149
|
+
INSERT INTO {{schema}}.deliveries (event_id, endpoint_id)
|
|
150
|
+
SELECT CAST(:event_id AS uuid), targets.id FROM ({targets}) AS targets
|
|
151
|
+
RETURNING id
|
|
152
|
+
), wake AS (
|
|
153
|
+
SELECT pg_notify(CAST(:channel AS text), '') FROM created LIMIT 1
|
|
154
|
+
)
|
|
155
|
+
SELECT id, (SELECT count(*) FROM wake) AS notified FROM created ORDER BY id
|
|
156
|
+
""",
|
|
157
|
+
insert_params,
|
|
158
|
+
)
|
|
159
|
+
if endpoint_id is not None and not rows:
|
|
160
|
+
msg = f"endpoint {endpoint_id} not found for the owner of event {event_id}"
|
|
161
|
+
raise NotFoundError(msg)
|
|
162
|
+
return [row["id"] for row in rows]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def send_test(
|
|
166
|
+
endpoint_id: uuid.UUID,
|
|
167
|
+
*,
|
|
168
|
+
owner_id: OwnerFilter,
|
|
169
|
+
channel: str,
|
|
170
|
+
) -> Operation[PublishResult]:
|
|
171
|
+
"""Send a ``webhook.test`` event to one endpoint, regardless of its subscriptions."""
|
|
172
|
+
params: dict[str, Any] = {"endpoint_id": str(endpoint_id)}
|
|
173
|
+
owner_sql = owner_condition("e.owner_id", owner_id, params)
|
|
174
|
+
rows = yield Query(
|
|
175
|
+
f"""
|
|
176
|
+
SELECT e.id, e.owner_id, e.is_enabled FROM {{schema}}.endpoints AS e
|
|
177
|
+
WHERE e.id = CAST(:endpoint_id AS uuid) AND {owner_sql}
|
|
178
|
+
FOR SHARE
|
|
179
|
+
""",
|
|
180
|
+
params,
|
|
181
|
+
)
|
|
182
|
+
endpoint = first_or_not_found(rows, "endpoint", endpoint_id)
|
|
183
|
+
if not endpoint["is_enabled"]:
|
|
184
|
+
msg = f"endpoint {endpoint_id} is disabled; enable it before sending a test event"
|
|
185
|
+
raise ConflictError(msg)
|
|
186
|
+
|
|
187
|
+
event_id = uuid7()
|
|
188
|
+
payload = {
|
|
189
|
+
"message": "This is a test event sent by pgdispatch.",
|
|
190
|
+
"endpoint_id": str(endpoint_id),
|
|
191
|
+
}
|
|
192
|
+
yield Query(
|
|
193
|
+
"""
|
|
194
|
+
WITH new_event AS (
|
|
195
|
+
INSERT INTO {schema}.events (id, event_type, owner_id, payload)
|
|
196
|
+
VALUES (CAST(:event_id AS uuid), CAST(:event_type AS text),
|
|
197
|
+
CAST(:owner_id AS text), CAST(:payload AS jsonb))
|
|
198
|
+
RETURNING id
|
|
199
|
+
), delivery AS (
|
|
200
|
+
INSERT INTO {schema}.deliveries (event_id, endpoint_id)
|
|
201
|
+
SELECT new_event.id, CAST(:endpoint_id AS uuid) FROM new_event
|
|
202
|
+
RETURNING 1
|
|
203
|
+
), wake AS (
|
|
204
|
+
SELECT pg_notify(CAST(:channel AS text), '') FROM delivery LIMIT 1
|
|
205
|
+
)
|
|
206
|
+
SELECT (SELECT count(*) FROM delivery) AS deliveries,
|
|
207
|
+
(SELECT count(*) FROM wake) AS notified
|
|
208
|
+
""",
|
|
209
|
+
{
|
|
210
|
+
"event_id": str(event_id),
|
|
211
|
+
"event_type": TEST_EVENT_TYPE,
|
|
212
|
+
"owner_id": endpoint["owner_id"],
|
|
213
|
+
"payload": serialize_payload(payload, max_bytes=1024),
|
|
214
|
+
"endpoint_id": str(endpoint_id),
|
|
215
|
+
"channel": channel,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
return PublishResult(event_id=event_id, deliveries=1)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _fanout_targets(owner_id: str | None, params: dict[str, Any]) -> str:
|
|
222
|
+
owner_match = _owner_match(owner_id, params, column="e.owner_id")
|
|
223
|
+
return _FANOUT_TARGETS.replace("{owner_match}", owner_match)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _owner_match(owner_id: str | None, params: dict[str, Any], *, column: str) -> str:
|
|
227
|
+
# Events are only ever delivered to endpoints of the same owner (or, for
|
|
228
|
+
# events without an owner, to endpoints without an owner).
|
|
229
|
+
if owner_id is None:
|
|
230
|
+
return f"{column} IS NULL"
|
|
231
|
+
params["target_owner"] = owner_id
|
|
232
|
+
return f"{column} = CAST(:target_owner AS text)"
|
pgdispatch/core/retry.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Retry schedule, jitter and ``Retry-After`` handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"DEFAULT_RETRY_SCHEDULE",
|
|
11
|
+
"exponential_backoff",
|
|
12
|
+
"next_retry_delay",
|
|
13
|
+
"parse_retry_after",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
#: Eight attempts over roughly 27 hours, the schedule popularised by Stripe and Svix.
|
|
17
|
+
#: Element *n* is the delay before attempt *n + 1*; the first attempt is immediate.
|
|
18
|
+
DEFAULT_RETRY_SCHEDULE: tuple[timedelta, ...] = (
|
|
19
|
+
timedelta(0),
|
|
20
|
+
timedelta(seconds=5),
|
|
21
|
+
timedelta(minutes=5),
|
|
22
|
+
timedelta(minutes=30),
|
|
23
|
+
timedelta(hours=2),
|
|
24
|
+
timedelta(hours=5),
|
|
25
|
+
timedelta(hours=10),
|
|
26
|
+
timedelta(hours=10),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def exponential_backoff(
|
|
31
|
+
attempts: int,
|
|
32
|
+
*,
|
|
33
|
+
initial: timedelta = timedelta(seconds=5),
|
|
34
|
+
factor: float = 2.0,
|
|
35
|
+
max_delay: timedelta = timedelta(hours=10),
|
|
36
|
+
) -> tuple[timedelta, ...]:
|
|
37
|
+
"""Build a retry schedule whose delays grow geometrically.
|
|
38
|
+
|
|
39
|
+
``exponential_backoff(4, initial=timedelta(seconds=10))`` yields delays of
|
|
40
|
+
0 s, 10 s, 20 s and 40 s. The result can be passed as ``retry_schedule``.
|
|
41
|
+
"""
|
|
42
|
+
if attempts < 1:
|
|
43
|
+
msg = "attempts must be at least 1"
|
|
44
|
+
raise ValueError(msg)
|
|
45
|
+
if factor < 1:
|
|
46
|
+
msg = "factor must be at least 1"
|
|
47
|
+
raise ValueError(msg)
|
|
48
|
+
delays = [timedelta(0)]
|
|
49
|
+
for retry in range(attempts - 1):
|
|
50
|
+
delays.append(min(initial * factor**retry, max_delay))
|
|
51
|
+
return tuple(delays)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def next_retry_delay(
|
|
55
|
+
schedule: tuple[timedelta, ...],
|
|
56
|
+
attempts_made: int,
|
|
57
|
+
*,
|
|
58
|
+
jitter: float = 0.0,
|
|
59
|
+
rng: random.Random | None = None,
|
|
60
|
+
) -> timedelta | None:
|
|
61
|
+
"""Return the delay before the next attempt, or ``None`` when attempts are exhausted.
|
|
62
|
+
|
|
63
|
+
``attempts_made`` counts the attempts already performed in the current retry
|
|
64
|
+
round. A random spread of ``±jitter`` is applied so that retries for many
|
|
65
|
+
deliveries that failed together do not hit the receiver at the same instant.
|
|
66
|
+
"""
|
|
67
|
+
if attempts_made >= len(schedule):
|
|
68
|
+
return None
|
|
69
|
+
delay = schedule[attempts_made]
|
|
70
|
+
if jitter and delay:
|
|
71
|
+
spread = (rng or random).uniform(1 - jitter, 1 + jitter)
|
|
72
|
+
delay = delay * spread
|
|
73
|
+
return delay
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_retry_after(value: str | None, *, now: datetime | None = None) -> timedelta | None:
|
|
77
|
+
"""Parse an HTTP ``Retry-After`` header (delta-seconds or HTTP-date)."""
|
|
78
|
+
if not value:
|
|
79
|
+
return None
|
|
80
|
+
value = value.strip()
|
|
81
|
+
if value.isdigit():
|
|
82
|
+
return timedelta(seconds=int(value))
|
|
83
|
+
try:
|
|
84
|
+
moment = parsedate_to_datetime(value)
|
|
85
|
+
except (TypeError, ValueError, IndexError):
|
|
86
|
+
return None
|
|
87
|
+
if moment.tzinfo is None:
|
|
88
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
89
|
+
delay = moment - (now or datetime.now(timezone.utc))
|
|
90
|
+
return max(delay, timedelta(0))
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Storage of endpoint signing secrets, including rotation and optional encryption.
|
|
2
|
+
|
|
3
|
+
An endpoint keeps a list of secrets in a ``jsonb`` column::
|
|
4
|
+
|
|
5
|
+
[{"key": "whsec_...", "encrypted": false,
|
|
6
|
+
"created_at": "2026-11-01T10:00:00+00:00", "expires_at": null}]
|
|
7
|
+
|
|
8
|
+
The newest secret comes first. When a secret is rotated the previous ones get
|
|
9
|
+
an ``expires_at`` timestamp; until then requests are signed with every active
|
|
10
|
+
secret, so receivers can switch keys without dropping a single webhook.
|
|
11
|
+
|
|
12
|
+
Secrets cannot be hashed because the worker needs them to sign requests. They
|
|
13
|
+
can, however, be encrypted at rest with any object implementing
|
|
14
|
+
:class:`SecretCipher` — ``cryptography.fernet.Fernet`` and ``MultiFernet`` fit.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
from typing import Any, Protocol
|
|
24
|
+
|
|
25
|
+
from pgdispatch.errors import ConfigurationError
|
|
26
|
+
|
|
27
|
+
__all__ = ["SecretCipher", "SecretEntry", "SecretStore", "fernet_cipher"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SecretCipher(Protocol):
|
|
31
|
+
"""Symmetric encryption used for secrets at rest (``Fernet`` compatible)."""
|
|
32
|
+
|
|
33
|
+
def encrypt(self, data: bytes) -> bytes: ...
|
|
34
|
+
|
|
35
|
+
def decrypt(self, token: bytes) -> bytes: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class SecretEntry:
|
|
40
|
+
key: str
|
|
41
|
+
encrypted: bool
|
|
42
|
+
created_at: datetime
|
|
43
|
+
expires_at: datetime | None
|
|
44
|
+
|
|
45
|
+
def is_active(self, now: datetime) -> bool:
|
|
46
|
+
return self.expires_at is None or self.expires_at > now
|
|
47
|
+
|
|
48
|
+
def to_json(self) -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"key": self.key,
|
|
51
|
+
"encrypted": self.encrypted,
|
|
52
|
+
"created_at": self.created_at.isoformat(),
|
|
53
|
+
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_json(cls, data: dict[str, Any]) -> SecretEntry:
|
|
58
|
+
expires_at = data.get("expires_at")
|
|
59
|
+
return cls(
|
|
60
|
+
key=data["key"],
|
|
61
|
+
encrypted=bool(data.get("encrypted", False)),
|
|
62
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
63
|
+
expires_at=datetime.fromisoformat(expires_at) if expires_at else None,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SecretStore:
|
|
68
|
+
"""Seals, opens and rotates the secrets of an endpoint."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, cipher: SecretCipher | None = None) -> None:
|
|
71
|
+
self._cipher = cipher
|
|
72
|
+
|
|
73
|
+
def seal(self, secret: str, *, now: datetime) -> SecretEntry:
|
|
74
|
+
if self._cipher is None:
|
|
75
|
+
return SecretEntry(key=secret, encrypted=False, created_at=now, expires_at=None)
|
|
76
|
+
token = self._cipher.encrypt(secret.encode()).decode("ascii")
|
|
77
|
+
return SecretEntry(key=token, encrypted=True, created_at=now, expires_at=None)
|
|
78
|
+
|
|
79
|
+
def open(self, entry: SecretEntry) -> str:
|
|
80
|
+
if not entry.encrypted:
|
|
81
|
+
return entry.key
|
|
82
|
+
if self._cipher is None:
|
|
83
|
+
msg = (
|
|
84
|
+
"endpoint secrets are encrypted but no decryption key is configured; "
|
|
85
|
+
"set secret_encryption_keys (PGDISPATCH_SECRET_ENCRYPTION_KEYS)"
|
|
86
|
+
)
|
|
87
|
+
raise ConfigurationError(msg)
|
|
88
|
+
return self._cipher.decrypt(entry.key.encode("ascii")).decode()
|
|
89
|
+
|
|
90
|
+
def active_secrets(self, raw: str | Sequence[dict[str, Any]], *, now: datetime) -> list[str]:
|
|
91
|
+
"""Plaintext secrets that must sign a request at ``now``, newest first."""
|
|
92
|
+
return [self.open(entry) for entry in self.load(raw) if entry.is_active(now)]
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def load(raw: str | Sequence[dict[str, Any]]) -> list[SecretEntry]:
|
|
96
|
+
items = json.loads(raw) if isinstance(raw, str) else raw
|
|
97
|
+
return [SecretEntry.from_json(item) for item in items]
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def dump(entries: Sequence[SecretEntry]) -> str:
|
|
101
|
+
return json.dumps([entry.to_json() for entry in entries])
|
|
102
|
+
|
|
103
|
+
def rotate(
|
|
104
|
+
self,
|
|
105
|
+
entries: Sequence[SecretEntry],
|
|
106
|
+
new_secret: str,
|
|
107
|
+
*,
|
|
108
|
+
now: datetime,
|
|
109
|
+
grace_period: timedelta,
|
|
110
|
+
) -> list[SecretEntry]:
|
|
111
|
+
"""Put ``new_secret`` first; currently active secrets expire after ``grace_period``."""
|
|
112
|
+
deadline = now + grace_period
|
|
113
|
+
kept = [
|
|
114
|
+
SecretEntry(
|
|
115
|
+
key=entry.key,
|
|
116
|
+
encrypted=entry.encrypted,
|
|
117
|
+
created_at=entry.created_at,
|
|
118
|
+
expires_at=min(entry.expires_at, deadline) if entry.expires_at else deadline,
|
|
119
|
+
)
|
|
120
|
+
for entry in entries
|
|
121
|
+
if entry.is_active(now) and grace_period > timedelta(0)
|
|
122
|
+
]
|
|
123
|
+
return [self.seal(new_secret, now=now), *kept]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def fernet_cipher(keys: Sequence[str]) -> SecretCipher:
|
|
127
|
+
"""Build a ``MultiFernet`` cipher; the first key encrypts, all keys decrypt."""
|
|
128
|
+
try:
|
|
129
|
+
from cryptography.fernet import Fernet, MultiFernet
|
|
130
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
131
|
+
msg = (
|
|
132
|
+
'secret encryption requires the encryption extra: pip install "pgdispatch[encryption]"'
|
|
133
|
+
)
|
|
134
|
+
raise ConfigurationError(msg) from exc
|
|
135
|
+
if not keys:
|
|
136
|
+
msg = "at least one Fernet key is required"
|
|
137
|
+
raise ConfigurationError(msg)
|
|
138
|
+
return MultiFernet([Fernet(key) for key in keys])
|