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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webhookd-sdk
3
- Version: 0.2.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.2.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.2.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