webhookd-sdk 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/PKG-INFO +53 -1
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/README.md +48 -0
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/pyproject.toml +3 -1
- webhookd_sdk-0.3.0/tests/test_outbox.py +293 -0
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/webhookd_sdk/__init__.py +17 -1
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/webhookd_sdk/client.py +120 -0
- webhookd_sdk-0.3.0/webhookd_sdk/outbox.py +546 -0
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/.gitignore +0 -0
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/LICENSE +0 -0
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/tests/test_client.py +0 -0
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/tests/test_e2e_webhookd.py +0 -0
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/tests/test_signature.py +0 -0
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/tests/test_vectors.py +0 -0
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/webhookd_sdk/errors.py +0 -0
- {webhookd_sdk-0.2.0 → webhookd_sdk-0.3.0}/webhookd_sdk/signature.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webhookd-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Official Python SDK for NimbusNexus Webhooks — publish events + verify webhook signatures.
|
|
5
5
|
Project-URL: Homepage, https://github.com/NimbusNexus/Webhooks-sdks/tree/develop/python#readme
|
|
6
6
|
Project-URL: Repository, https://github.com/NimbusNexus/Webhooks-sdks
|
|
@@ -24,6 +24,10 @@ Provides-Extra: dev
|
|
|
24
24
|
Requires-Dist: mypy; extra == 'dev'
|
|
25
25
|
Requires-Dist: pytest; extra == 'dev'
|
|
26
26
|
Requires-Dist: ruff; extra == 'dev'
|
|
27
|
+
Provides-Extra: postgres
|
|
28
|
+
Requires-Dist: psycopg[binary]>=3; extra == 'postgres'
|
|
29
|
+
Provides-Extra: redis
|
|
30
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
27
31
|
Description-Content-Type: text/markdown
|
|
28
32
|
|
|
29
33
|
# webhookd-sdk (Python)
|
|
@@ -75,6 +79,54 @@ with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
|
|
|
75
79
|
Transient failures (connection errors, `429`, `5xx`) are retried with backoff (a `429` honours
|
|
76
80
|
`Retry-After`); other `4xx` raise `WebhookdAPIError`.
|
|
77
81
|
|
|
82
|
+
## Outbox / durable buffering (producers)
|
|
83
|
+
|
|
84
|
+
`publish()` calls webhookd synchronously — if webhookd is unreachable it raises and the event is
|
|
85
|
+
lost. The **write-first outbox** decouples the two: `enqueue()` durably persists the event to a
|
|
86
|
+
pluggable `Store` and returns IMMEDIATELY (no network); `drain()` (or a background drainer) ships the
|
|
87
|
+
buffered events later. Every send carries `Idempotency-Key = record.id`, so a re-drain after a crash
|
|
88
|
+
or a lost response never double-publishes — webhookd dedupes. Delivery is **at-least-once**: nothing
|
|
89
|
+
is lost while webhookd is down.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from webhookd_sdk import Client, SQLiteStore
|
|
93
|
+
|
|
94
|
+
# 1. Configure a durable store (survives process restarts).
|
|
95
|
+
store = SQLiteStore("outbox.db")
|
|
96
|
+
|
|
97
|
+
with Client("https://webhooks.example.com", api_key="whsk_…", store=store) as wh:
|
|
98
|
+
# 2. enqueue() instead of publish() — writes to the store and returns at once, NO network call.
|
|
99
|
+
record_id = wh.enqueue("order.created", {"order_id": "ord_123", "total": 4200})
|
|
100
|
+
|
|
101
|
+
# 3a. Drain on demand (returns {"sent", "failed", "remaining"}):
|
|
102
|
+
wh.drain()
|
|
103
|
+
|
|
104
|
+
# 3b. …or run a background drainer that calls drain() every 5s until the client closes.
|
|
105
|
+
wh.start_drainer(interval_seconds=5)
|
|
106
|
+
# ... your app keeps enqueuing; the drainer ships in the background ...
|
|
107
|
+
wh.stop_drainer() # also called automatically by Client.close()/__exit__
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Idempotency guarantee.** `record_id` is the `idempotency_key` you pass (or a generated UUID v4) and
|
|
111
|
+
becomes the `Idempotency-Key` header on every delivery attempt for that record. If the process
|
|
112
|
+
crashes after a send but before the response is recorded, the next `drain()` re-sends with the *same*
|
|
113
|
+
key and webhookd returns the original event without re-fanning-out. A record that keeps failing is
|
|
114
|
+
retried with capped exponential backoff up to `max_attempts` (default 10), then flagged **dead**
|
|
115
|
+
(never retried again) and handed to the optional `on_dead` callback.
|
|
116
|
+
|
|
117
|
+
**Built-in stores** — pick one for `Client(..., store=...)`:
|
|
118
|
+
|
|
119
|
+
| Store | Durable? | Extra needed |
|
|
120
|
+
| --- | --- | --- |
|
|
121
|
+
| `MemoryStore` | No (in-process) | — (stdlib) |
|
|
122
|
+
| `FileStore(dir)` | Yes (per-record JSON files) | — (stdlib) |
|
|
123
|
+
| `SQLiteStore(path)` | Yes (transactional) | — (stdlib `sqlite3`) |
|
|
124
|
+
| `RedisStore(url)` | Yes | `pip install 'webhookd-sdk[redis]'` |
|
|
125
|
+
| `PostgresStore(dsn)` | Yes | `pip install 'webhookd-sdk[postgres]'` |
|
|
126
|
+
|
|
127
|
+
The core SDK stays zero-dependency; `RedisStore` / `PostgresStore` lazily import their driver only
|
|
128
|
+
when you construct them.
|
|
129
|
+
|
|
78
130
|
## Manage endpoints, keys & deliveries (operators)
|
|
79
131
|
|
|
80
132
|
The same `Client` wraps the control-plane API — register receivers, mint keys, and drain the
|
|
@@ -47,6 +47,54 @@ with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
|
|
|
47
47
|
Transient failures (connection errors, `429`, `5xx`) are retried with backoff (a `429` honours
|
|
48
48
|
`Retry-After`); other `4xx` raise `WebhookdAPIError`.
|
|
49
49
|
|
|
50
|
+
## Outbox / durable buffering (producers)
|
|
51
|
+
|
|
52
|
+
`publish()` calls webhookd synchronously — if webhookd is unreachable it raises and the event is
|
|
53
|
+
lost. The **write-first outbox** decouples the two: `enqueue()` durably persists the event to a
|
|
54
|
+
pluggable `Store` and returns IMMEDIATELY (no network); `drain()` (or a background drainer) ships the
|
|
55
|
+
buffered events later. Every send carries `Idempotency-Key = record.id`, so a re-drain after a crash
|
|
56
|
+
or a lost response never double-publishes — webhookd dedupes. Delivery is **at-least-once**: nothing
|
|
57
|
+
is lost while webhookd is down.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from webhookd_sdk import Client, SQLiteStore
|
|
61
|
+
|
|
62
|
+
# 1. Configure a durable store (survives process restarts).
|
|
63
|
+
store = SQLiteStore("outbox.db")
|
|
64
|
+
|
|
65
|
+
with Client("https://webhooks.example.com", api_key="whsk_…", store=store) as wh:
|
|
66
|
+
# 2. enqueue() instead of publish() — writes to the store and returns at once, NO network call.
|
|
67
|
+
record_id = wh.enqueue("order.created", {"order_id": "ord_123", "total": 4200})
|
|
68
|
+
|
|
69
|
+
# 3a. Drain on demand (returns {"sent", "failed", "remaining"}):
|
|
70
|
+
wh.drain()
|
|
71
|
+
|
|
72
|
+
# 3b. …or run a background drainer that calls drain() every 5s until the client closes.
|
|
73
|
+
wh.start_drainer(interval_seconds=5)
|
|
74
|
+
# ... your app keeps enqueuing; the drainer ships in the background ...
|
|
75
|
+
wh.stop_drainer() # also called automatically by Client.close()/__exit__
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Idempotency guarantee.** `record_id` is the `idempotency_key` you pass (or a generated UUID v4) and
|
|
79
|
+
becomes the `Idempotency-Key` header on every delivery attempt for that record. If the process
|
|
80
|
+
crashes after a send but before the response is recorded, the next `drain()` re-sends with the *same*
|
|
81
|
+
key and webhookd returns the original event without re-fanning-out. A record that keeps failing is
|
|
82
|
+
retried with capped exponential backoff up to `max_attempts` (default 10), then flagged **dead**
|
|
83
|
+
(never retried again) and handed to the optional `on_dead` callback.
|
|
84
|
+
|
|
85
|
+
**Built-in stores** — pick one for `Client(..., store=...)`:
|
|
86
|
+
|
|
87
|
+
| Store | Durable? | Extra needed |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `MemoryStore` | No (in-process) | — (stdlib) |
|
|
90
|
+
| `FileStore(dir)` | Yes (per-record JSON files) | — (stdlib) |
|
|
91
|
+
| `SQLiteStore(path)` | Yes (transactional) | — (stdlib `sqlite3`) |
|
|
92
|
+
| `RedisStore(url)` | Yes | `pip install 'webhookd-sdk[redis]'` |
|
|
93
|
+
| `PostgresStore(dsn)` | Yes | `pip install 'webhookd-sdk[postgres]'` |
|
|
94
|
+
|
|
95
|
+
The core SDK stays zero-dependency; `RedisStore` / `PostgresStore` lazily import their driver only
|
|
96
|
+
when you construct them.
|
|
97
|
+
|
|
50
98
|
## Manage endpoints, keys & deliveries (operators)
|
|
51
99
|
|
|
52
100
|
The same `Client` wraps the control-plane API — register receivers, mint keys, and drain the
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webhookd-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Official Python SDK for NimbusNexus Webhooks — publish events + verify webhook signatures."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -30,6 +30,8 @@ Repository = "https://github.com/NimbusNexus/Webhooks-sdks"
|
|
|
30
30
|
Issues = "https://github.com/NimbusNexus/Webhooks-sdks/issues"
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
33
|
+
redis = ["redis>=5"]
|
|
34
|
+
postgres = ["psycopg[binary]>=3"]
|
|
33
35
|
dev = ["pytest", "ruff", "mypy"]
|
|
34
36
|
|
|
35
37
|
[tool.hatch.build.targets.wheel]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Write-first async outbox — a SHARED store-contract test parametrized over every Store, plus the
|
|
2
|
+
client enqueue/drain flow driven through an httpx MockTransport (no real webhookd).
|
|
3
|
+
|
|
4
|
+
Memory / File / SQLite run fully and locally (no server). Redis and Postgres run the SAME contract
|
|
5
|
+
only when ``WEBHOOKD_TEST_REDIS_URL`` / ``WEBHOOKD_TEST_PG_URL`` point at a reachable server; they
|
|
6
|
+
skip cleanly otherwise.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from webhookd_sdk import (
|
|
22
|
+
Client,
|
|
23
|
+
FileStore,
|
|
24
|
+
MemoryStore,
|
|
25
|
+
PostgresStore,
|
|
26
|
+
Record,
|
|
27
|
+
RedisStore,
|
|
28
|
+
SQLiteStore,
|
|
29
|
+
Store,
|
|
30
|
+
)
|
|
31
|
+
from webhookd_sdk import client as client_mod
|
|
32
|
+
|
|
33
|
+
_OK = {
|
|
34
|
+
"id": "evt_db",
|
|
35
|
+
"event_uid": "u1",
|
|
36
|
+
"event_type": "order.created",
|
|
37
|
+
"application": "default",
|
|
38
|
+
"environment": "prod",
|
|
39
|
+
"deliveries_created": 1,
|
|
40
|
+
"source": None,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _record(rid: str, *, created_at: float, next_attempt_at: float | None = None) -> Record:
|
|
45
|
+
return Record(
|
|
46
|
+
id=rid,
|
|
47
|
+
event_type="order.created",
|
|
48
|
+
payload={"id": rid},
|
|
49
|
+
created_at=created_at,
|
|
50
|
+
next_attempt_at=created_at if next_attempt_at is None else next_attempt_at,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# -- store factories: memory/file/sqlite always run; redis/pg skip unless a server is configured ---
|
|
55
|
+
def _make_memory(tmp_path: Any) -> Store:
|
|
56
|
+
return MemoryStore()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _make_file(tmp_path: Any) -> Store:
|
|
60
|
+
return FileStore(tmp_path / "outbox")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _make_sqlite(tmp_path: Any) -> Store:
|
|
64
|
+
return SQLiteStore(str(tmp_path / "outbox.db"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _make_redis(tmp_path: Any) -> Store:
|
|
68
|
+
url = os.environ.get("WEBHOOKD_TEST_REDIS_URL")
|
|
69
|
+
if not url:
|
|
70
|
+
pytest.skip("set WEBHOOKD_TEST_REDIS_URL to run the Redis store contract")
|
|
71
|
+
try:
|
|
72
|
+
store = RedisStore(url, namespace=f"test:{uuid.uuid4()}")
|
|
73
|
+
store._redis.ping() # type: ignore[attr-defined]
|
|
74
|
+
except Exception as exc: # noqa: BLE001 — unreachable server => skip, never fail
|
|
75
|
+
pytest.skip(f"Redis unreachable: {exc}")
|
|
76
|
+
return store
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _make_pg(tmp_path: Any) -> Store:
|
|
80
|
+
dsn = os.environ.get("WEBHOOKD_TEST_PG_URL")
|
|
81
|
+
if not dsn:
|
|
82
|
+
pytest.skip("set WEBHOOKD_TEST_PG_URL to run the Postgres store contract")
|
|
83
|
+
try:
|
|
84
|
+
table = f"webhookd_outbox_test_{uuid.uuid4().hex}"
|
|
85
|
+
store = PostgresStore(dsn, table=table)
|
|
86
|
+
except Exception as exc: # noqa: BLE001 — unreachable server => skip, never fail
|
|
87
|
+
pytest.skip(f"Postgres unreachable: {exc}")
|
|
88
|
+
return store
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.fixture(
|
|
92
|
+
params=[_make_memory, _make_file, _make_sqlite, _make_redis, _make_pg],
|
|
93
|
+
ids=["memory", "file", "sqlite", "redis", "postgres"],
|
|
94
|
+
)
|
|
95
|
+
def store(request: pytest.FixtureRequest, tmp_path: Any) -> Iterator[Store]:
|
|
96
|
+
s = request.param(tmp_path)
|
|
97
|
+
try:
|
|
98
|
+
yield s
|
|
99
|
+
finally:
|
|
100
|
+
s.close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# -- shared store contract ----------------------------------------------------
|
|
104
|
+
def test_save_and_size(store: Store) -> None:
|
|
105
|
+
assert store.size() == 0
|
|
106
|
+
store.save(_record("a", created_at=1.0))
|
|
107
|
+
store.save(_record("b", created_at=2.0))
|
|
108
|
+
assert store.size() == 2
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_save_is_upsert_by_id(store: Store) -> None:
|
|
112
|
+
store.save(_record("a", created_at=1.0))
|
|
113
|
+
updated = _record("a", created_at=1.0)
|
|
114
|
+
updated.payload = {"changed": True}
|
|
115
|
+
store.save(updated)
|
|
116
|
+
assert store.size() == 1 # same id overwrote, not appended
|
|
117
|
+
rows = store.list_pending(10)
|
|
118
|
+
assert len(rows) == 1 and rows[0].payload == {"changed": True}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_list_pending_orders_oldest_first(store: Store) -> None:
|
|
122
|
+
store.save(_record("newer", created_at=200.0))
|
|
123
|
+
store.save(_record("older", created_at=100.0))
|
|
124
|
+
ids = [r.id for r in store.list_pending(10)]
|
|
125
|
+
assert ids == ["older", "newer"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_list_pending_filters_not_yet_due(store: Store) -> None:
|
|
129
|
+
now = time.time()
|
|
130
|
+
store.save(_record("due", created_at=now - 10, next_attempt_at=now - 5))
|
|
131
|
+
store.save(_record("future", created_at=now - 10, next_attempt_at=now + 3600))
|
|
132
|
+
ids = [r.id for r in store.list_pending(10)]
|
|
133
|
+
assert ids == ["due"] # the future record is withheld until its next_attempt_at
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_list_pending_respects_limit(store: Store) -> None:
|
|
137
|
+
for i in range(5):
|
|
138
|
+
store.save(_record(f"r{i}", created_at=float(i)))
|
|
139
|
+
assert len(store.list_pending(3)) == 3
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_mark_sent_removes(store: Store) -> None:
|
|
143
|
+
store.save(_record("a", created_at=1.0))
|
|
144
|
+
store.mark_sent("a")
|
|
145
|
+
assert store.size() == 0
|
|
146
|
+
assert store.list_pending(10) == []
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_mark_failed_reschedules_and_bumps_attempts(store: Store) -> None:
|
|
150
|
+
now = time.time()
|
|
151
|
+
store.save(_record("a", created_at=now - 10, next_attempt_at=now - 5))
|
|
152
|
+
# reschedule into the future -> no longer due, but still pending (not lost)
|
|
153
|
+
store.mark_failed("a", "boom", 3, now + 3600)
|
|
154
|
+
assert store.size() == 1
|
|
155
|
+
assert store.list_pending(10) == []
|
|
156
|
+
# bring it back due and confirm the failure fields persisted
|
|
157
|
+
store.mark_failed("a", "boom-again", 4, now - 1)
|
|
158
|
+
rows = store.list_pending(10)
|
|
159
|
+
assert len(rows) == 1
|
|
160
|
+
assert rows[0].attempts == 4 and rows[0].last_error == "boom-again"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# -- durability (survives a fresh store over the same backing) ----------------
|
|
164
|
+
def test_file_store_survives_restart(tmp_path: Any) -> None:
|
|
165
|
+
path = tmp_path / "durable"
|
|
166
|
+
s1 = FileStore(path)
|
|
167
|
+
s1.save(_record("a", created_at=1.0))
|
|
168
|
+
s1.close()
|
|
169
|
+
s2 = FileStore(path)
|
|
170
|
+
assert s2.size() == 1 and s2.list_pending(10)[0].id == "a"
|
|
171
|
+
s2.close()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_sqlite_store_survives_restart(tmp_path: Any) -> None:
|
|
175
|
+
path = str(tmp_path / "durable.db")
|
|
176
|
+
s1 = SQLiteStore(path)
|
|
177
|
+
s1.save(_record("a", created_at=1.0))
|
|
178
|
+
s1.close()
|
|
179
|
+
s2 = SQLiteStore(path)
|
|
180
|
+
assert s2.size() == 1 and s2.list_pending(10)[0].id == "a"
|
|
181
|
+
s2.close()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# -- client enqueue/drain flow (mock transport, no real webhookd) -------------
|
|
185
|
+
def _client(handler: Any, *, store: Store, **kw: Any) -> Client:
|
|
186
|
+
return Client(
|
|
187
|
+
"https://wh.example.com",
|
|
188
|
+
"whsk_x",
|
|
189
|
+
transport=httpx.MockTransport(handler),
|
|
190
|
+
store=store,
|
|
191
|
+
**kw,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_enqueue_writes_no_network_then_drain_sends_all_with_idem_headers() -> None:
|
|
196
|
+
seen: list[dict[str, Any]] = []
|
|
197
|
+
|
|
198
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
199
|
+
seen.append(
|
|
200
|
+
{
|
|
201
|
+
"idem": request.headers.get("Idempotency-Key"),
|
|
202
|
+
"body": json.loads(request.content),
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
return httpx.Response(201, json=_OK)
|
|
206
|
+
|
|
207
|
+
store = MemoryStore()
|
|
208
|
+
with _client(handler, store=store) as c:
|
|
209
|
+
ids = [c.enqueue("order.created", {"n": i}) for i in range(3)]
|
|
210
|
+
assert store.size() == 3 and seen == [] # enqueue made no network call
|
|
211
|
+
result = c.drain()
|
|
212
|
+
|
|
213
|
+
assert result == {"sent": 3, "failed": 0, "remaining": 0}
|
|
214
|
+
assert [s["idem"] for s in seen] == ids # each send carried its record.id as the key
|
|
215
|
+
assert all(s["body"]["environment"] == "prod" for s in seen)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_supplied_idempotency_key_is_used_as_id_and_header() -> None:
|
|
219
|
+
seen: dict[str, str | None] = {}
|
|
220
|
+
|
|
221
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
222
|
+
seen["idem"] = request.headers.get("Idempotency-Key")
|
|
223
|
+
return httpx.Response(201, json=_OK)
|
|
224
|
+
|
|
225
|
+
store = MemoryStore()
|
|
226
|
+
with _client(handler, store=store) as c:
|
|
227
|
+
rid = c.enqueue("order.created", {"id": 1}, idempotency_key="order-123")
|
|
228
|
+
assert rid == "order-123"
|
|
229
|
+
c.drain()
|
|
230
|
+
assert seen["idem"] == "order-123"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_failing_transport_marks_failed_not_sent_then_retries_same_id(
|
|
234
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
235
|
+
) -> None:
|
|
236
|
+
# zero the backoff so the rescheduled record is immediately due on the next drain
|
|
237
|
+
monkeypatch.setattr(client_mod, "_backoff", lambda attempt: 0.0)
|
|
238
|
+
keys: list[str | None] = []
|
|
239
|
+
calls = {"n": 0}
|
|
240
|
+
|
|
241
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
242
|
+
keys.append(request.headers.get("Idempotency-Key"))
|
|
243
|
+
calls["n"] += 1
|
|
244
|
+
if calls["n"] == 1:
|
|
245
|
+
return httpx.Response(503, json={"error": {"code": "unavailable", "message": "down"}})
|
|
246
|
+
return httpx.Response(201, json=_OK)
|
|
247
|
+
|
|
248
|
+
store = MemoryStore()
|
|
249
|
+
with _client(handler, store=store, max_retries=0) as c:
|
|
250
|
+
rid = c.enqueue("order.created", {"id": 1})
|
|
251
|
+
first = c.drain() # webhookd down -> failed, kept, attempts bumped
|
|
252
|
+
assert first == {"sent": 0, "failed": 1, "remaining": 1}
|
|
253
|
+
pending = store.list_pending(10)
|
|
254
|
+
assert len(pending) == 1 and pending[0].attempts == 1 and not pending[0].sent
|
|
255
|
+
second = c.drain() # webhookd back -> sent
|
|
256
|
+
assert second == {"sent": 1, "failed": 0, "remaining": 0}
|
|
257
|
+
|
|
258
|
+
assert keys == [rid, rid] # the SAME record.id is reused on retry (idempotent)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_max_attempts_ceiling_flags_dead_and_fires_hook(
|
|
262
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
263
|
+
) -> None:
|
|
264
|
+
monkeypatch.setattr(client_mod, "_backoff", lambda attempt: 0.0)
|
|
265
|
+
|
|
266
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
267
|
+
return httpx.Response(500, json={"error": {"code": "boom", "message": "always"}})
|
|
268
|
+
|
|
269
|
+
dead: list[Record] = []
|
|
270
|
+
store = MemoryStore()
|
|
271
|
+
with _client(handler, store=store, max_retries=0, on_dead=dead.append) as c:
|
|
272
|
+
c.enqueue("order.created", {"id": 1})
|
|
273
|
+
result = c.drain(max_attempts=1) # 1 failure == the ceiling -> dead, not retried
|
|
274
|
+
|
|
275
|
+
assert result == {"sent": 0, "failed": 1, "remaining": 0} # dead is excluded from remaining
|
|
276
|
+
assert store.list_pending(10) == [] # never retried again
|
|
277
|
+
assert len(dead) == 1 and dead[0].dead is True
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_background_drainer_starts_and_stops_cleanly() -> None:
|
|
281
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
282
|
+
return httpx.Response(201, json=_OK)
|
|
283
|
+
|
|
284
|
+
store = MemoryStore()
|
|
285
|
+
with _client(handler, store=store) as c:
|
|
286
|
+
c.enqueue("order.created", {"id": 1})
|
|
287
|
+
c.start_drainer(0.01)
|
|
288
|
+
deadline = time.time() + 2.0
|
|
289
|
+
while store.size() > 0 and time.time() < deadline:
|
|
290
|
+
time.sleep(0.01)
|
|
291
|
+
c.stop_drainer()
|
|
292
|
+
assert store.size() == 0
|
|
293
|
+
assert c._drainer is None # joined cleanly
|
|
@@ -8,16 +8,32 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from webhookd_sdk.client import Client, Event
|
|
10
10
|
from webhookd_sdk.errors import WebhookdAPIError, WebhookdError
|
|
11
|
+
from webhookd_sdk.outbox import (
|
|
12
|
+
FileStore,
|
|
13
|
+
MemoryStore,
|
|
14
|
+
PostgresStore,
|
|
15
|
+
Record,
|
|
16
|
+
RedisStore,
|
|
17
|
+
SQLiteStore,
|
|
18
|
+
Store,
|
|
19
|
+
)
|
|
11
20
|
from webhookd_sdk.signature import DEFAULT_TOLERANCE_SECONDS, sign, verify
|
|
12
21
|
|
|
13
22
|
# Keep in lockstep with pyproject.toml + the release tag (see publish.yml's bump checklist).
|
|
14
|
-
__version__ = "0.
|
|
23
|
+
__version__ = "0.3.0"
|
|
15
24
|
|
|
16
25
|
__all__ = [
|
|
17
26
|
"Client",
|
|
18
27
|
"Event",
|
|
19
28
|
"WebhookdAPIError",
|
|
20
29
|
"WebhookdError",
|
|
30
|
+
"Store",
|
|
31
|
+
"Record",
|
|
32
|
+
"MemoryStore",
|
|
33
|
+
"FileStore",
|
|
34
|
+
"SQLiteStore",
|
|
35
|
+
"RedisStore",
|
|
36
|
+
"PostgresStore",
|
|
21
37
|
"DEFAULT_TOLERANCE_SECONDS",
|
|
22
38
|
"sign",
|
|
23
39
|
"verify",
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import threading
|
|
5
6
|
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Callable
|
|
6
9
|
from dataclasses import dataclass
|
|
7
10
|
from typing import Any
|
|
8
11
|
|
|
9
12
|
import httpx
|
|
10
13
|
|
|
11
14
|
from webhookd_sdk.errors import WebhookdAPIError, WebhookdError
|
|
15
|
+
from webhookd_sdk.outbox import Record, Store
|
|
12
16
|
|
|
13
17
|
_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
14
18
|
|
|
@@ -59,11 +63,17 @@ class Client:
|
|
|
59
63
|
timeout: float = 10.0,
|
|
60
64
|
max_retries: int = 2,
|
|
61
65
|
transport: httpx.BaseTransport | None = None,
|
|
66
|
+
store: Store | None = None,
|
|
67
|
+
on_dead: Callable[[Record], None] | None = None,
|
|
62
68
|
):
|
|
63
69
|
self._base = base_url.rstrip("/")
|
|
64
70
|
self._key = api_key
|
|
65
71
|
self._max_retries = max_retries
|
|
66
72
|
self._client = httpx.Client(timeout=timeout, transport=transport)
|
|
73
|
+
self._store = store
|
|
74
|
+
self._on_dead = on_dead
|
|
75
|
+
self._drainer: threading.Thread | None = None
|
|
76
|
+
self._drainer_stop = threading.Event()
|
|
67
77
|
|
|
68
78
|
def publish(
|
|
69
79
|
self,
|
|
@@ -91,6 +101,115 @@ class Client:
|
|
|
91
101
|
resp = self._request("POST", "/v1/events", json=body, extra_headers=extra_headers)
|
|
92
102
|
return Event._from_json(resp.json())
|
|
93
103
|
|
|
104
|
+
# -- write-first async outbox ----------------------------------------------
|
|
105
|
+
def enqueue(
|
|
106
|
+
self,
|
|
107
|
+
event_type: str,
|
|
108
|
+
payload: dict[str, Any],
|
|
109
|
+
*,
|
|
110
|
+
idempotency_key: str | None = None,
|
|
111
|
+
environment: str = "prod",
|
|
112
|
+
application: str = "default",
|
|
113
|
+
source: str | None = None,
|
|
114
|
+
) -> str:
|
|
115
|
+
"""Durably buffer one event and return its id WITHOUT any network call.
|
|
116
|
+
|
|
117
|
+
The id is ``idempotency_key`` (if given) or a fresh UUID v4; it becomes the
|
|
118
|
+
``Idempotency-Key`` on every later :meth:`drain` send, so re-draining after a crash never
|
|
119
|
+
double-publishes. Requires a ``store`` to be configured on the client.
|
|
120
|
+
"""
|
|
121
|
+
if self._store is None:
|
|
122
|
+
raise WebhookdError("enqueue requires a store — construct Client(..., store=...)")
|
|
123
|
+
now = time.time()
|
|
124
|
+
record = Record(
|
|
125
|
+
id=idempotency_key or str(uuid.uuid4()),
|
|
126
|
+
event_type=event_type,
|
|
127
|
+
payload=payload,
|
|
128
|
+
environment=environment,
|
|
129
|
+
application=application,
|
|
130
|
+
source=source,
|
|
131
|
+
created_at=now,
|
|
132
|
+
next_attempt_at=now,
|
|
133
|
+
)
|
|
134
|
+
self._store.save(record)
|
|
135
|
+
return record.id
|
|
136
|
+
|
|
137
|
+
def drain(self, *, limit: int = 100, max_attempts: int = 10) -> dict[str, int]:
|
|
138
|
+
"""Send buffered records to webhookd. Returns ``{"sent", "failed", "remaining"}``.
|
|
139
|
+
|
|
140
|
+
For each due record it POSTs ``/v1/events`` with header ``Idempotency-Key = record.id``.
|
|
141
|
+
On 2xx the record is removed (``mark_sent``). On failure ``attempts`` is bumped, the next
|
|
142
|
+
retry is scheduled with capped exponential backoff, and the record is kept; once
|
|
143
|
+
``attempts`` reaches ``max_attempts`` the record is flagged dead (not retried again) and
|
|
144
|
+
handed to the ``on_dead`` callback if configured. ``remaining`` is the pending count.
|
|
145
|
+
"""
|
|
146
|
+
if self._store is None:
|
|
147
|
+
raise WebhookdError("drain requires a store — construct Client(..., store=...)")
|
|
148
|
+
sent = 0
|
|
149
|
+
failed = 0
|
|
150
|
+
for record in self._store.list_pending(limit):
|
|
151
|
+
body: dict[str, Any] = {
|
|
152
|
+
"event_type": record.event_type,
|
|
153
|
+
"payload": record.payload,
|
|
154
|
+
"environment": record.environment,
|
|
155
|
+
"application": record.application,
|
|
156
|
+
}
|
|
157
|
+
if record.source is not None:
|
|
158
|
+
body["source"] = record.source
|
|
159
|
+
try:
|
|
160
|
+
self._request(
|
|
161
|
+
"POST", "/v1/events", json=body, extra_headers={"Idempotency-Key": record.id}
|
|
162
|
+
)
|
|
163
|
+
except WebhookdError as exc:
|
|
164
|
+
record.attempts += 1
|
|
165
|
+
record.last_error = str(exc)
|
|
166
|
+
if record.attempts >= max_attempts:
|
|
167
|
+
record.dead = True
|
|
168
|
+
self._store.save(record)
|
|
169
|
+
if self._on_dead is not None:
|
|
170
|
+
self._on_dead(record)
|
|
171
|
+
else:
|
|
172
|
+
record.next_attempt_at = time.time() + _backoff(record.attempts)
|
|
173
|
+
self._store.mark_failed(
|
|
174
|
+
record.id, record.last_error, record.attempts, record.next_attempt_at
|
|
175
|
+
)
|
|
176
|
+
failed += 1
|
|
177
|
+
else:
|
|
178
|
+
self._store.mark_sent(record.id)
|
|
179
|
+
sent += 1
|
|
180
|
+
return {"sent": sent, "failed": failed, "remaining": self._store.size()}
|
|
181
|
+
|
|
182
|
+
def start_drainer(
|
|
183
|
+
self, interval_seconds: float, *, limit: int = 100, max_attempts: int = 10
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Start a daemon thread that calls :meth:`drain` every ``interval_seconds`` until
|
|
186
|
+
:meth:`stop_drainer` (or :meth:`close`). A per-drain exception is swallowed so a transient
|
|
187
|
+
outage never kills the loop. Idempotent — a second call while one is running is a no-op."""
|
|
188
|
+
if self._store is None:
|
|
189
|
+
raise WebhookdError("start_drainer requires a store — construct Client(..., store=...)")
|
|
190
|
+
if self._drainer is not None and self._drainer.is_alive():
|
|
191
|
+
return
|
|
192
|
+
self._drainer_stop.clear()
|
|
193
|
+
|
|
194
|
+
def _loop() -> None:
|
|
195
|
+
while not self._drainer_stop.is_set():
|
|
196
|
+
try:
|
|
197
|
+
self.drain(limit=limit, max_attempts=max_attempts)
|
|
198
|
+
except Exception: # noqa: BLE001 — keep the loop alive across transient failures
|
|
199
|
+
pass
|
|
200
|
+
self._drainer_stop.wait(interval_seconds)
|
|
201
|
+
|
|
202
|
+
self._drainer = threading.Thread(target=_loop, name="webhookd-drainer", daemon=True)
|
|
203
|
+
self._drainer.start()
|
|
204
|
+
|
|
205
|
+
def stop_drainer(self, *, timeout: float | None = 5.0) -> None:
|
|
206
|
+
"""Signal the background drainer to stop and join it. Safe to call when none is running."""
|
|
207
|
+
self._drainer_stop.set()
|
|
208
|
+
drainer = self._drainer
|
|
209
|
+
if drainer is not None:
|
|
210
|
+
drainer.join(timeout)
|
|
211
|
+
self._drainer = None
|
|
212
|
+
|
|
94
213
|
# -- endpoints -------------------------------------------------------------
|
|
95
214
|
def create_endpoint(
|
|
96
215
|
self,
|
|
@@ -229,6 +348,7 @@ class Client:
|
|
|
229
348
|
return self._request("POST", f"/v1/deliveries/{delivery_id}/redeliver").json()
|
|
230
349
|
|
|
231
350
|
def close(self) -> None:
|
|
351
|
+
self.stop_drainer()
|
|
232
352
|
self._client.close()
|
|
233
353
|
|
|
234
354
|
def __enter__(self) -> Client:
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""Write-first async outbox — a durable buffer for publishing events.
|
|
2
|
+
|
|
3
|
+
The producer calls :meth:`Client.enqueue` which writes an event :class:`Record` to a pluggable
|
|
4
|
+
:class:`Store` and returns IMMEDIATELY (no network). A later :meth:`Client.drain` (or the background
|
|
5
|
+
drainer started via :meth:`Client.start_drainer`) POSTs the buffered records to webhookd. Every send
|
|
6
|
+
carries ``Idempotency-Key = record.id`` so a re-drain after a crash or a lost response never
|
|
7
|
+
double-publishes (webhookd dedupes). Delivery is at-least-once: nothing is lost while webhookd is
|
|
8
|
+
down.
|
|
9
|
+
|
|
10
|
+
Five stores ship in the box: :class:`MemoryStore` (in-process, non-durable — the default/tests),
|
|
11
|
+
:class:`FileStore` (a directory of per-record JSON files with atomic writes), :class:`SQLiteStore`
|
|
12
|
+
(stdlib ``sqlite3``, transactional) — all runnable with no server — plus :class:`RedisStore` and
|
|
13
|
+
:class:`PostgresStore`, which lazily import their optional drivers (``pip install
|
|
14
|
+
'webhookd-sdk[redis]'`` / ``'[postgres]'``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sqlite3
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from abc import ABC, abstractmethod
|
|
25
|
+
from dataclasses import asdict, dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
from urllib.parse import quote
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Record:
|
|
32
|
+
"""One buffered event. ``id`` is the ``Idempotency-Key`` (caller-supplied or a UUID v4)."""
|
|
33
|
+
|
|
34
|
+
id: str
|
|
35
|
+
event_type: str
|
|
36
|
+
payload: dict[str, Any]
|
|
37
|
+
environment: str = "prod"
|
|
38
|
+
application: str = "default"
|
|
39
|
+
source: str | None = None
|
|
40
|
+
created_at: float = field(default_factory=time.time)
|
|
41
|
+
attempts: int = 0
|
|
42
|
+
last_error: str | None = None
|
|
43
|
+
next_attempt_at: float = field(default_factory=time.time)
|
|
44
|
+
sent: bool = False
|
|
45
|
+
dead: bool = False
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, Any]:
|
|
48
|
+
return asdict(self)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, d: dict[str, Any]) -> Record:
|
|
52
|
+
return cls(
|
|
53
|
+
id=d["id"],
|
|
54
|
+
event_type=d["event_type"],
|
|
55
|
+
payload=d["payload"],
|
|
56
|
+
environment=d.get("environment", "prod"),
|
|
57
|
+
application=d.get("application", "default"),
|
|
58
|
+
source=d.get("source"),
|
|
59
|
+
created_at=d.get("created_at", 0.0),
|
|
60
|
+
attempts=d.get("attempts", 0),
|
|
61
|
+
last_error=d.get("last_error"),
|
|
62
|
+
next_attempt_at=d.get("next_attempt_at", 0.0),
|
|
63
|
+
sent=bool(d.get("sent", False)),
|
|
64
|
+
dead=bool(d.get("dead", False)),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _clone(record: Record) -> Record:
|
|
69
|
+
"""A deep-ish copy so a store never hands out an object the caller can mutate in place."""
|
|
70
|
+
return Record.from_dict(json.loads(json.dumps(record.to_dict())))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Store(ABC):
|
|
74
|
+
"""Durable buffer of pending :class:`Record`s. All methods are safe to call from the drainer
|
|
75
|
+
thread and the caller thread concurrently."""
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def save(self, record: Record) -> None:
|
|
79
|
+
"""Insert-or-UPDATE by ``record.id`` — the same id overwrites, so enqueue is idempotent."""
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def list_pending(self, limit: int) -> list[Record]:
|
|
83
|
+
"""Records not yet sent (and not dead) whose ``next_attempt_at <= now``, oldest first (by
|
|
84
|
+
``created_at``), capped at ``limit``."""
|
|
85
|
+
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def mark_sent(self, id: str) -> None:
|
|
88
|
+
"""Remove a record after a 2xx."""
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def mark_failed(
|
|
92
|
+
self, id: str, error: str | None, attempts: int, next_attempt_at: float
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Persist the failure and schedule the next retry."""
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def size(self) -> int:
|
|
98
|
+
"""Count of pending (not sent, not dead) records."""
|
|
99
|
+
|
|
100
|
+
def close(self) -> None: # noqa: B027 — optional hook; concrete stores override when needed
|
|
101
|
+
"""Release any resources. The default is a no-op."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# -- MemoryStore --------------------------------------------------------------
|
|
105
|
+
class MemoryStore(Store):
|
|
106
|
+
"""In-process, non-durable store. The default — ideal for tests and for callers that only want
|
|
107
|
+
the enqueue/drain shape without persistence."""
|
|
108
|
+
|
|
109
|
+
def __init__(self) -> None:
|
|
110
|
+
self._rows: dict[str, Record] = {}
|
|
111
|
+
self._lock = threading.Lock()
|
|
112
|
+
|
|
113
|
+
def save(self, record: Record) -> None:
|
|
114
|
+
with self._lock:
|
|
115
|
+
self._rows[record.id] = _clone(record)
|
|
116
|
+
|
|
117
|
+
def list_pending(self, limit: int) -> list[Record]:
|
|
118
|
+
now = time.time()
|
|
119
|
+
with self._lock:
|
|
120
|
+
due = [
|
|
121
|
+
r
|
|
122
|
+
for r in self._rows.values()
|
|
123
|
+
if not r.sent and not r.dead and r.next_attempt_at <= now
|
|
124
|
+
]
|
|
125
|
+
due.sort(key=lambda r: r.created_at)
|
|
126
|
+
return [_clone(r) for r in due[:limit]]
|
|
127
|
+
|
|
128
|
+
def mark_sent(self, id: str) -> None:
|
|
129
|
+
with self._lock:
|
|
130
|
+
self._rows.pop(id, None)
|
|
131
|
+
|
|
132
|
+
def mark_failed(
|
|
133
|
+
self, id: str, error: str | None, attempts: int, next_attempt_at: float
|
|
134
|
+
) -> None:
|
|
135
|
+
with self._lock:
|
|
136
|
+
r = self._rows.get(id)
|
|
137
|
+
if r is not None:
|
|
138
|
+
r.last_error = error
|
|
139
|
+
r.attempts = attempts
|
|
140
|
+
r.next_attempt_at = next_attempt_at
|
|
141
|
+
|
|
142
|
+
def size(self) -> int:
|
|
143
|
+
with self._lock:
|
|
144
|
+
return sum(1 for r in self._rows.values() if not r.sent and not r.dead)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# -- FileStore ----------------------------------------------------------------
|
|
148
|
+
class FileStore(Store):
|
|
149
|
+
"""Durable on-disk store: one JSON file per record in ``directory``, written atomically
|
|
150
|
+
(temp file + ``os.replace``) so a crash mid-write never corrupts a record. Survives process
|
|
151
|
+
restart — a fresh :class:`FileStore` over the same directory sees every un-sent record."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, directory: str | os.PathLike[str]) -> None:
|
|
154
|
+
self._dir = os.fspath(directory)
|
|
155
|
+
os.makedirs(self._dir, exist_ok=True)
|
|
156
|
+
self._lock = threading.Lock()
|
|
157
|
+
|
|
158
|
+
def _path(self, id: str) -> str:
|
|
159
|
+
return os.path.join(self._dir, quote(id, safe="") + ".json")
|
|
160
|
+
|
|
161
|
+
def _write(self, record: Record) -> None:
|
|
162
|
+
path = self._path(record.id)
|
|
163
|
+
tmp = f"{path}.{os.getpid()}.{threading.get_ident()}.tmp"
|
|
164
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
165
|
+
json.dump(record.to_dict(), fh)
|
|
166
|
+
os.replace(tmp, path)
|
|
167
|
+
|
|
168
|
+
def _read(self, path: str) -> Record | None:
|
|
169
|
+
try:
|
|
170
|
+
with open(path, encoding="utf-8") as fh:
|
|
171
|
+
return Record.from_dict(json.load(fh))
|
|
172
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def save(self, record: Record) -> None:
|
|
176
|
+
with self._lock:
|
|
177
|
+
self._write(record)
|
|
178
|
+
|
|
179
|
+
def _all(self) -> list[Record]:
|
|
180
|
+
rows: list[Record] = []
|
|
181
|
+
for name in os.listdir(self._dir):
|
|
182
|
+
if not name.endswith(".json"):
|
|
183
|
+
continue
|
|
184
|
+
r = self._read(os.path.join(self._dir, name))
|
|
185
|
+
if r is not None:
|
|
186
|
+
rows.append(r)
|
|
187
|
+
return rows
|
|
188
|
+
|
|
189
|
+
def list_pending(self, limit: int) -> list[Record]:
|
|
190
|
+
now = time.time()
|
|
191
|
+
with self._lock:
|
|
192
|
+
due = [r for r in self._all() if not r.sent and not r.dead and r.next_attempt_at <= now]
|
|
193
|
+
due.sort(key=lambda r: r.created_at)
|
|
194
|
+
return due[:limit]
|
|
195
|
+
|
|
196
|
+
def mark_sent(self, id: str) -> None:
|
|
197
|
+
with self._lock:
|
|
198
|
+
try:
|
|
199
|
+
os.remove(self._path(id))
|
|
200
|
+
except FileNotFoundError:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
def mark_failed(
|
|
204
|
+
self, id: str, error: str | None, attempts: int, next_attempt_at: float
|
|
205
|
+
) -> None:
|
|
206
|
+
with self._lock:
|
|
207
|
+
r = self._read(self._path(id))
|
|
208
|
+
if r is not None:
|
|
209
|
+
r.last_error = error
|
|
210
|
+
r.attempts = attempts
|
|
211
|
+
r.next_attempt_at = next_attempt_at
|
|
212
|
+
self._write(r)
|
|
213
|
+
|
|
214
|
+
def size(self) -> int:
|
|
215
|
+
with self._lock:
|
|
216
|
+
return sum(1 for r in self._all() if not r.sent and not r.dead)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# -- SQLiteStore --------------------------------------------------------------
|
|
220
|
+
_SQLITE_DDL = """
|
|
221
|
+
CREATE TABLE IF NOT EXISTS webhookd_outbox (
|
|
222
|
+
id TEXT PRIMARY KEY,
|
|
223
|
+
event_type TEXT NOT NULL,
|
|
224
|
+
payload TEXT NOT NULL,
|
|
225
|
+
environment TEXT NOT NULL,
|
|
226
|
+
application TEXT NOT NULL,
|
|
227
|
+
source TEXT,
|
|
228
|
+
created_at REAL NOT NULL,
|
|
229
|
+
attempts INTEGER NOT NULL,
|
|
230
|
+
last_error TEXT,
|
|
231
|
+
next_attempt_at REAL NOT NULL,
|
|
232
|
+
sent INTEGER NOT NULL DEFAULT 0,
|
|
233
|
+
dead INTEGER NOT NULL DEFAULT 0
|
|
234
|
+
)
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class SQLiteStore(Store):
|
|
239
|
+
"""Durable, transactional store on stdlib ``sqlite3`` — no native dependency. Point it at a file
|
|
240
|
+
path (``":memory:"`` is non-durable). Upserts on ``id``; safe across the drainer thread via a
|
|
241
|
+
lock plus ``check_same_thread=False``."""
|
|
242
|
+
|
|
243
|
+
def __init__(self, path: str | os.PathLike[str]) -> None:
|
|
244
|
+
self._conn = sqlite3.connect(os.fspath(path), check_same_thread=False)
|
|
245
|
+
self._lock = threading.Lock()
|
|
246
|
+
with self._lock:
|
|
247
|
+
self._conn.execute(_SQLITE_DDL)
|
|
248
|
+
self._conn.commit()
|
|
249
|
+
|
|
250
|
+
@staticmethod
|
|
251
|
+
def _row_to_record(row: tuple[Any, ...]) -> Record:
|
|
252
|
+
return Record(
|
|
253
|
+
id=row[0],
|
|
254
|
+
event_type=row[1],
|
|
255
|
+
payload=json.loads(row[2]),
|
|
256
|
+
environment=row[3],
|
|
257
|
+
application=row[4],
|
|
258
|
+
source=row[5],
|
|
259
|
+
created_at=row[6],
|
|
260
|
+
attempts=row[7],
|
|
261
|
+
last_error=row[8],
|
|
262
|
+
next_attempt_at=row[9],
|
|
263
|
+
sent=bool(row[10]),
|
|
264
|
+
dead=bool(row[11]),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def save(self, record: Record) -> None:
|
|
268
|
+
with self._lock:
|
|
269
|
+
self._conn.execute(
|
|
270
|
+
"""
|
|
271
|
+
INSERT INTO webhookd_outbox
|
|
272
|
+
(id, event_type, payload, environment, application, source,
|
|
273
|
+
created_at, attempts, last_error, next_attempt_at, sent, dead)
|
|
274
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
275
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
276
|
+
event_type=excluded.event_type,
|
|
277
|
+
payload=excluded.payload,
|
|
278
|
+
environment=excluded.environment,
|
|
279
|
+
application=excluded.application,
|
|
280
|
+
source=excluded.source,
|
|
281
|
+
created_at=excluded.created_at,
|
|
282
|
+
attempts=excluded.attempts,
|
|
283
|
+
last_error=excluded.last_error,
|
|
284
|
+
next_attempt_at=excluded.next_attempt_at,
|
|
285
|
+
sent=excluded.sent,
|
|
286
|
+
dead=excluded.dead
|
|
287
|
+
""",
|
|
288
|
+
(
|
|
289
|
+
record.id,
|
|
290
|
+
record.event_type,
|
|
291
|
+
json.dumps(record.payload),
|
|
292
|
+
record.environment,
|
|
293
|
+
record.application,
|
|
294
|
+
record.source,
|
|
295
|
+
record.created_at,
|
|
296
|
+
record.attempts,
|
|
297
|
+
record.last_error,
|
|
298
|
+
record.next_attempt_at,
|
|
299
|
+
int(record.sent),
|
|
300
|
+
int(record.dead),
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
self._conn.commit()
|
|
304
|
+
|
|
305
|
+
def list_pending(self, limit: int) -> list[Record]:
|
|
306
|
+
now = time.time()
|
|
307
|
+
with self._lock:
|
|
308
|
+
cur = self._conn.execute(
|
|
309
|
+
"""
|
|
310
|
+
SELECT id, event_type, payload, environment, application, source,
|
|
311
|
+
created_at, attempts, last_error, next_attempt_at, sent, dead
|
|
312
|
+
FROM webhookd_outbox
|
|
313
|
+
WHERE sent = 0 AND dead = 0 AND next_attempt_at <= ?
|
|
314
|
+
ORDER BY created_at ASC
|
|
315
|
+
LIMIT ?
|
|
316
|
+
""",
|
|
317
|
+
(now, limit),
|
|
318
|
+
)
|
|
319
|
+
rows = cur.fetchall()
|
|
320
|
+
return [self._row_to_record(r) for r in rows]
|
|
321
|
+
|
|
322
|
+
def mark_sent(self, id: str) -> None:
|
|
323
|
+
with self._lock:
|
|
324
|
+
self._conn.execute("DELETE FROM webhookd_outbox WHERE id = ?", (id,))
|
|
325
|
+
self._conn.commit()
|
|
326
|
+
|
|
327
|
+
def mark_failed(
|
|
328
|
+
self, id: str, error: str | None, attempts: int, next_attempt_at: float
|
|
329
|
+
) -> None:
|
|
330
|
+
with self._lock:
|
|
331
|
+
self._conn.execute(
|
|
332
|
+
"""
|
|
333
|
+
UPDATE webhookd_outbox
|
|
334
|
+
SET last_error = ?, attempts = ?, next_attempt_at = ?
|
|
335
|
+
WHERE id = ?
|
|
336
|
+
""",
|
|
337
|
+
(error, attempts, next_attempt_at, id),
|
|
338
|
+
)
|
|
339
|
+
self._conn.commit()
|
|
340
|
+
|
|
341
|
+
def size(self) -> int:
|
|
342
|
+
with self._lock:
|
|
343
|
+
cur = self._conn.execute(
|
|
344
|
+
"SELECT COUNT(*) FROM webhookd_outbox WHERE sent = 0 AND dead = 0"
|
|
345
|
+
)
|
|
346
|
+
return int(cur.fetchone()[0])
|
|
347
|
+
|
|
348
|
+
def close(self) -> None:
|
|
349
|
+
with self._lock:
|
|
350
|
+
self._conn.close()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# -- RedisStore ---------------------------------------------------------------
|
|
354
|
+
class RedisStore(Store):
|
|
355
|
+
"""Durable store on Redis: a sorted set keyed on ``next_attempt_at`` holds the due-ordering of
|
|
356
|
+
pending ids, and a hash holds the record bodies. Sent/dead records are removed from the sorted
|
|
357
|
+
set so they are never retried. Requires the optional ``redis`` driver (lazily imported)."""
|
|
358
|
+
|
|
359
|
+
def __init__(self, url: str, *, namespace: str = "webhookd:outbox") -> None:
|
|
360
|
+
import redis # lazy: optional extra [redis]
|
|
361
|
+
|
|
362
|
+
# Typed as Any: the driver is an optional dependency, so pin nothing to its concrete types.
|
|
363
|
+
self._redis: Any = redis.Redis.from_url(url, decode_responses=True)
|
|
364
|
+
self._zkey = f"{namespace}:pending"
|
|
365
|
+
self._hkey = f"{namespace}:records"
|
|
366
|
+
|
|
367
|
+
def save(self, record: Record) -> None:
|
|
368
|
+
pipe = self._redis.pipeline()
|
|
369
|
+
pipe.hset(self._hkey, record.id, json.dumps(record.to_dict()))
|
|
370
|
+
if record.sent or record.dead:
|
|
371
|
+
pipe.zrem(self._zkey, record.id)
|
|
372
|
+
else:
|
|
373
|
+
pipe.zadd(self._zkey, {record.id: record.next_attempt_at})
|
|
374
|
+
pipe.execute()
|
|
375
|
+
|
|
376
|
+
def list_pending(self, limit: int) -> list[Record]:
|
|
377
|
+
now = time.time()
|
|
378
|
+
ids = list(self._redis.zrangebyscore(self._zkey, "-inf", now))
|
|
379
|
+
if not ids:
|
|
380
|
+
return []
|
|
381
|
+
bodies = self._redis.hmget(self._hkey, ids)
|
|
382
|
+
rows = [Record.from_dict(json.loads(b)) for b in bodies if b is not None]
|
|
383
|
+
rows.sort(key=lambda r: r.created_at)
|
|
384
|
+
return rows[:limit]
|
|
385
|
+
|
|
386
|
+
def mark_sent(self, id: str) -> None:
|
|
387
|
+
pipe = self._redis.pipeline()
|
|
388
|
+
pipe.zrem(self._zkey, id)
|
|
389
|
+
pipe.hdel(self._hkey, id)
|
|
390
|
+
pipe.execute()
|
|
391
|
+
|
|
392
|
+
def mark_failed(
|
|
393
|
+
self, id: str, error: str | None, attempts: int, next_attempt_at: float
|
|
394
|
+
) -> None:
|
|
395
|
+
body = self._redis.hget(self._hkey, id)
|
|
396
|
+
if body is None:
|
|
397
|
+
return
|
|
398
|
+
r = Record.from_dict(json.loads(body))
|
|
399
|
+
r.last_error = error
|
|
400
|
+
r.attempts = attempts
|
|
401
|
+
r.next_attempt_at = next_attempt_at
|
|
402
|
+
pipe = self._redis.pipeline()
|
|
403
|
+
pipe.hset(self._hkey, id, json.dumps(r.to_dict()))
|
|
404
|
+
pipe.zadd(self._zkey, {id: next_attempt_at})
|
|
405
|
+
pipe.execute()
|
|
406
|
+
|
|
407
|
+
def size(self) -> int:
|
|
408
|
+
return int(self._redis.zcard(self._zkey))
|
|
409
|
+
|
|
410
|
+
def close(self) -> None:
|
|
411
|
+
self._redis.close()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# -- PostgresStore ------------------------------------------------------------
|
|
415
|
+
_PG_DDL = """
|
|
416
|
+
CREATE TABLE IF NOT EXISTS webhookd_outbox (
|
|
417
|
+
id TEXT PRIMARY KEY,
|
|
418
|
+
event_type TEXT NOT NULL,
|
|
419
|
+
payload JSONB NOT NULL,
|
|
420
|
+
environment TEXT NOT NULL,
|
|
421
|
+
application TEXT NOT NULL,
|
|
422
|
+
source TEXT,
|
|
423
|
+
created_at DOUBLE PRECISION NOT NULL,
|
|
424
|
+
attempts INTEGER NOT NULL,
|
|
425
|
+
last_error TEXT,
|
|
426
|
+
next_attempt_at DOUBLE PRECISION NOT NULL,
|
|
427
|
+
sent BOOLEAN NOT NULL DEFAULT FALSE,
|
|
428
|
+
dead BOOLEAN NOT NULL DEFAULT FALSE
|
|
429
|
+
)
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class PostgresStore(Store):
|
|
434
|
+
"""Durable, transactional store on PostgreSQL. Upserts on ``id``; ``list_pending`` selects
|
|
435
|
+
``WHERE NOT sent AND NOT dead AND next_attempt_at <= now ORDER BY created_at``. Requires the
|
|
436
|
+
optional ``psycopg`` (v3) driver (lazily imported)."""
|
|
437
|
+
|
|
438
|
+
def __init__(self, dsn: str, *, table: str = "webhookd_outbox") -> None:
|
|
439
|
+
import psycopg # lazy: optional extra [postgres]
|
|
440
|
+
|
|
441
|
+
self._conn = psycopg.connect(dsn, autocommit=True)
|
|
442
|
+
self._lock = threading.Lock()
|
|
443
|
+
# `table` is a fixed identifier chosen by the caller, not user input; EVERY statement below
|
|
444
|
+
# (CREATE + all CRUD) is templated with it, so a custom table name works end to end.
|
|
445
|
+
self._table = table
|
|
446
|
+
with self._lock, self._conn.cursor() as cur:
|
|
447
|
+
cur.execute(
|
|
448
|
+
_PG_DDL if table == "webhookd_outbox" else _PG_DDL.replace("webhookd_outbox", table)
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def save(self, record: Record) -> None:
|
|
452
|
+
with self._lock, self._conn.cursor() as cur:
|
|
453
|
+
cur.execute(
|
|
454
|
+
f"""
|
|
455
|
+
INSERT INTO {self._table}
|
|
456
|
+
(id, event_type, payload, environment, application, source,
|
|
457
|
+
created_at, attempts, last_error, next_attempt_at, sent, dead)
|
|
458
|
+
VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
459
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
460
|
+
event_type=EXCLUDED.event_type,
|
|
461
|
+
payload=EXCLUDED.payload,
|
|
462
|
+
environment=EXCLUDED.environment,
|
|
463
|
+
application=EXCLUDED.application,
|
|
464
|
+
source=EXCLUDED.source,
|
|
465
|
+
created_at=EXCLUDED.created_at,
|
|
466
|
+
attempts=EXCLUDED.attempts,
|
|
467
|
+
last_error=EXCLUDED.last_error,
|
|
468
|
+
next_attempt_at=EXCLUDED.next_attempt_at,
|
|
469
|
+
sent=EXCLUDED.sent,
|
|
470
|
+
dead=EXCLUDED.dead
|
|
471
|
+
""",
|
|
472
|
+
(
|
|
473
|
+
record.id,
|
|
474
|
+
record.event_type,
|
|
475
|
+
json.dumps(record.payload),
|
|
476
|
+
record.environment,
|
|
477
|
+
record.application,
|
|
478
|
+
record.source,
|
|
479
|
+
record.created_at,
|
|
480
|
+
record.attempts,
|
|
481
|
+
record.last_error,
|
|
482
|
+
record.next_attempt_at,
|
|
483
|
+
record.sent,
|
|
484
|
+
record.dead,
|
|
485
|
+
),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def list_pending(self, limit: int) -> list[Record]:
|
|
489
|
+
now = time.time()
|
|
490
|
+
with self._lock, self._conn.cursor() as cur:
|
|
491
|
+
cur.execute(
|
|
492
|
+
f"""
|
|
493
|
+
SELECT id, event_type, payload, environment, application, source,
|
|
494
|
+
created_at, attempts, last_error, next_attempt_at, sent, dead
|
|
495
|
+
FROM {self._table}
|
|
496
|
+
WHERE NOT sent AND NOT dead AND next_attempt_at <= %s
|
|
497
|
+
ORDER BY created_at ASC
|
|
498
|
+
LIMIT %s
|
|
499
|
+
""",
|
|
500
|
+
(now, limit),
|
|
501
|
+
)
|
|
502
|
+
rows = cur.fetchall()
|
|
503
|
+
return [
|
|
504
|
+
Record(
|
|
505
|
+
id=r[0],
|
|
506
|
+
event_type=r[1],
|
|
507
|
+
payload=r[2],
|
|
508
|
+
environment=r[3],
|
|
509
|
+
application=r[4],
|
|
510
|
+
source=r[5],
|
|
511
|
+
created_at=r[6],
|
|
512
|
+
attempts=r[7],
|
|
513
|
+
last_error=r[8],
|
|
514
|
+
next_attempt_at=r[9],
|
|
515
|
+
sent=r[10],
|
|
516
|
+
dead=r[11],
|
|
517
|
+
)
|
|
518
|
+
for r in rows
|
|
519
|
+
]
|
|
520
|
+
|
|
521
|
+
def mark_sent(self, id: str) -> None:
|
|
522
|
+
with self._lock, self._conn.cursor() as cur:
|
|
523
|
+
cur.execute(f"DELETE FROM {self._table} WHERE id = %s", (id,))
|
|
524
|
+
|
|
525
|
+
def mark_failed(
|
|
526
|
+
self, id: str, error: str | None, attempts: int, next_attempt_at: float
|
|
527
|
+
) -> None:
|
|
528
|
+
with self._lock, self._conn.cursor() as cur:
|
|
529
|
+
cur.execute(
|
|
530
|
+
f"""
|
|
531
|
+
UPDATE {self._table}
|
|
532
|
+
SET last_error = %s, attempts = %s, next_attempt_at = %s
|
|
533
|
+
WHERE id = %s
|
|
534
|
+
""",
|
|
535
|
+
(error, attempts, next_attempt_at, id),
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def size(self) -> int:
|
|
539
|
+
with self._lock, self._conn.cursor() as cur:
|
|
540
|
+
cur.execute(f"SELECT COUNT(*) FROM {self._table} WHERE NOT sent AND NOT dead")
|
|
541
|
+
row = cur.fetchone()
|
|
542
|
+
return int(row[0]) if row else 0
|
|
543
|
+
|
|
544
|
+
def close(self) -> None:
|
|
545
|
+
with self._lock:
|
|
546
|
+
self._conn.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|