hookrelay 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hookrelay/__init__.py +19 -0
- hookrelay/backends/__init__.py +4 -0
- hookrelay/backends/base.py +74 -0
- hookrelay/backends/memory.py +90 -0
- hookrelay/backends/postgres.py +200 -0
- hookrelay/backends/redis.py +192 -0
- hookrelay/backends/sqlite.py +223 -0
- hookrelay/cli.py +122 -0
- hookrelay/exceptions.py +10 -0
- hookrelay/fastapi.py +56 -0
- hookrelay/metrics.py +79 -0
- hookrelay/models.py +48 -0
- hookrelay/retry.py +37 -0
- hookrelay/worker.py +89 -0
- hookrelay-0.2.0.dist-info/METADATA +210 -0
- hookrelay-0.2.0.dist-info/RECORD +19 -0
- hookrelay-0.2.0.dist-info/WHEEL +4 -0
- hookrelay-0.2.0.dist-info/entry_points.txt +2 -0
- hookrelay-0.2.0.dist-info/licenses/LICENSE +21 -0
hookrelay/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from hookrelay.backends.base import Backend
|
|
2
|
+
from hookrelay.backends.memory import MemoryBackend
|
|
3
|
+
from hookrelay.exceptions import EventNotFoundError, HookRelayError
|
|
4
|
+
from hookrelay.models import EventStatus, WebhookEvent
|
|
5
|
+
from hookrelay.retry import RetryPolicy
|
|
6
|
+
from hookrelay.worker import Worker
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Backend",
|
|
12
|
+
"MemoryBackend",
|
|
13
|
+
"EventStatus",
|
|
14
|
+
"WebhookEvent",
|
|
15
|
+
"RetryPolicy",
|
|
16
|
+
"Worker",
|
|
17
|
+
"HookRelayError",
|
|
18
|
+
"EventNotFoundError",
|
|
19
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
|
|
6
|
+
from hookrelay.models import EventStatus, WebhookEvent
|
|
7
|
+
from hookrelay.retry import RetryPolicy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Backend(ABC):
|
|
11
|
+
"""Storage and scheduling contract shared by every hookrelay backend.
|
|
12
|
+
|
|
13
|
+
A backend only owns persistence and claiming semantics. The retry-vs-dead-letter
|
|
14
|
+
decision itself lives in `apply_failure()` below so every implementation applies
|
|
15
|
+
the exact same policy.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, retry_policy: RetryPolicy | None = None) -> None:
|
|
19
|
+
self.retry_policy = retry_policy or RetryPolicy()
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
async def enqueue(self, event: WebhookEvent) -> bool:
|
|
23
|
+
"""Persist a new event. Returns False without error if its idempotency_key
|
|
24
|
+
already exists (the event is treated as already accepted)."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
async def claim_due(self, limit: int) -> list[WebhookEvent]:
|
|
28
|
+
"""Atomically mark up to `limit` due pending events as processing and return them.
|
|
29
|
+
Safe to call concurrently from multiple workers: an event is claimed by at most one
|
|
30
|
+
caller."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def ack(self, event_id: str) -> None:
|
|
34
|
+
"""Mark an event as successfully processed."""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def fail(self, event_id: str, error: str) -> WebhookEvent:
|
|
38
|
+
"""Record a failed processing attempt, scheduling a retry or moving the event
|
|
39
|
+
to the dead-letter queue once `max_attempts` is exhausted. Returns the updated
|
|
40
|
+
event so callers can tell which of the two happened without a second lookup."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def list_dead_letters(self, limit: int = 100, offset: int = 0) -> list[WebhookEvent]:
|
|
44
|
+
"""List events that exhausted their retries, most recently failed first."""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def requeue_dead_letter(self, event_id: str) -> bool:
|
|
48
|
+
"""Reset a dead-lettered event back to pending for another full retry cycle.
|
|
49
|
+
Returns False if no dead-lettered event with that id exists."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def apply_failure(event: WebhookEvent, error: str, policy: RetryPolicy) -> WebhookEvent:
|
|
53
|
+
"""Returns a copy of `event` updated after a failed processing attempt.
|
|
54
|
+
|
|
55
|
+
Centralizes the retry-count/backoff/dead-letter decision so every backend
|
|
56
|
+
(Postgres, Redis, memory, ...) applies identical semantics.
|
|
57
|
+
"""
|
|
58
|
+
now = datetime.now(timezone.utc)
|
|
59
|
+
updated = event.model_copy(
|
|
60
|
+
update={
|
|
61
|
+
"attempts": event.attempts + 1,
|
|
62
|
+
"last_error": error,
|
|
63
|
+
"updated_at": now,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
if updated.exhausted:
|
|
67
|
+
return updated.model_copy(update={"status": EventStatus.DEAD_LETTER})
|
|
68
|
+
delay = policy.delay_for_attempt(updated.attempts)
|
|
69
|
+
return updated.model_copy(
|
|
70
|
+
update={
|
|
71
|
+
"status": EventStatus.PENDING,
|
|
72
|
+
"next_retry_at": now + timedelta(seconds=delay),
|
|
73
|
+
}
|
|
74
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from hookrelay.backends.base import Backend, apply_failure
|
|
7
|
+
from hookrelay.exceptions import EventNotFoundError
|
|
8
|
+
from hookrelay.models import EventStatus, WebhookEvent
|
|
9
|
+
from hookrelay.retry import RetryPolicy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MemoryBackend(Backend):
|
|
13
|
+
"""In-process backend backed by a plain dict.
|
|
14
|
+
|
|
15
|
+
Intended for local development, examples, and tests: it has no persistence
|
|
16
|
+
and does not coordinate across processes. Use PostgresBackend or RedisBackend
|
|
17
|
+
in production.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, retry_policy: RetryPolicy | None = None) -> None:
|
|
21
|
+
super().__init__(retry_policy)
|
|
22
|
+
self._events: dict[str, WebhookEvent] = {}
|
|
23
|
+
self._idempotency_keys: set[str] = set()
|
|
24
|
+
self._lock = asyncio.Lock()
|
|
25
|
+
|
|
26
|
+
async def enqueue(self, event: WebhookEvent) -> bool:
|
|
27
|
+
async with self._lock:
|
|
28
|
+
if event.idempotency_key is not None:
|
|
29
|
+
if event.idempotency_key in self._idempotency_keys:
|
|
30
|
+
return False
|
|
31
|
+
self._idempotency_keys.add(event.idempotency_key)
|
|
32
|
+
self._events[event.id] = event
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
async def claim_due(self, limit: int) -> list[WebhookEvent]:
|
|
36
|
+
now = datetime.now(timezone.utc)
|
|
37
|
+
async with self._lock:
|
|
38
|
+
due = sorted(
|
|
39
|
+
(
|
|
40
|
+
e
|
|
41
|
+
for e in self._events.values()
|
|
42
|
+
if e.status == EventStatus.PENDING and e.next_retry_at <= now
|
|
43
|
+
),
|
|
44
|
+
key=lambda e: e.next_retry_at,
|
|
45
|
+
)[:limit]
|
|
46
|
+
claimed = [e.model_copy(update={"status": EventStatus.PROCESSING}) for e in due]
|
|
47
|
+
for event in claimed:
|
|
48
|
+
self._events[event.id] = event
|
|
49
|
+
return claimed
|
|
50
|
+
|
|
51
|
+
async def ack(self, event_id: str) -> None:
|
|
52
|
+
async with self._lock:
|
|
53
|
+
event = self._events.get(event_id)
|
|
54
|
+
if event is not None:
|
|
55
|
+
self._events[event_id] = event.model_copy(update={"status": EventStatus.SUCCESS})
|
|
56
|
+
|
|
57
|
+
async def fail(self, event_id: str, error: str) -> WebhookEvent:
|
|
58
|
+
async with self._lock:
|
|
59
|
+
event = self._events.get(event_id)
|
|
60
|
+
if event is None:
|
|
61
|
+
raise EventNotFoundError(event_id)
|
|
62
|
+
updated = apply_failure(event, error, self.retry_policy)
|
|
63
|
+
self._events[event_id] = updated
|
|
64
|
+
return updated
|
|
65
|
+
|
|
66
|
+
async def list_dead_letters(self, limit: int = 100, offset: int = 0) -> list[WebhookEvent]:
|
|
67
|
+
async with self._lock:
|
|
68
|
+
dead = sorted(
|
|
69
|
+
(e for e in self._events.values() if e.status == EventStatus.DEAD_LETTER),
|
|
70
|
+
key=lambda e: e.updated_at,
|
|
71
|
+
reverse=True,
|
|
72
|
+
)
|
|
73
|
+
return dead[offset : offset + limit]
|
|
74
|
+
|
|
75
|
+
async def requeue_dead_letter(self, event_id: str) -> bool:
|
|
76
|
+
async with self._lock:
|
|
77
|
+
event = self._events.get(event_id)
|
|
78
|
+
if event is None or event.status != EventStatus.DEAD_LETTER:
|
|
79
|
+
return False
|
|
80
|
+
now = datetime.now(timezone.utc)
|
|
81
|
+
self._events[event_id] = event.model_copy(
|
|
82
|
+
update={
|
|
83
|
+
"status": EventStatus.PENDING,
|
|
84
|
+
"attempts": 0,
|
|
85
|
+
"last_error": None,
|
|
86
|
+
"next_retry_at": now,
|
|
87
|
+
"updated_at": now,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
return True
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import (
|
|
8
|
+
Column,
|
|
9
|
+
DateTime,
|
|
10
|
+
Index,
|
|
11
|
+
Integer,
|
|
12
|
+
MetaData,
|
|
13
|
+
Row,
|
|
14
|
+
String,
|
|
15
|
+
Table,
|
|
16
|
+
Text,
|
|
17
|
+
delete,
|
|
18
|
+
select,
|
|
19
|
+
update,
|
|
20
|
+
)
|
|
21
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
22
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
23
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
24
|
+
|
|
25
|
+
from hookrelay.backends.base import Backend, apply_failure
|
|
26
|
+
from hookrelay.exceptions import EventNotFoundError
|
|
27
|
+
from hookrelay.models import EventStatus, WebhookEvent
|
|
28
|
+
from hookrelay.retry import RetryPolicy
|
|
29
|
+
|
|
30
|
+
metadata = MetaData()
|
|
31
|
+
|
|
32
|
+
events_table = Table(
|
|
33
|
+
"hookrelay_events",
|
|
34
|
+
metadata,
|
|
35
|
+
Column("id", String(36), primary_key=True),
|
|
36
|
+
Column("source", String(255), nullable=False),
|
|
37
|
+
Column("idempotency_key", String(255), nullable=True, unique=True),
|
|
38
|
+
Column("payload", JSONB, nullable=False),
|
|
39
|
+
Column("headers", JSONB, nullable=False, server_default="{}"),
|
|
40
|
+
Column("status", String(20), nullable=False),
|
|
41
|
+
Column("attempts", Integer, nullable=False, server_default="0"),
|
|
42
|
+
Column("max_attempts", Integer, nullable=False),
|
|
43
|
+
Column("last_error", Text, nullable=True),
|
|
44
|
+
Column("next_retry_at", DateTime(timezone=True), nullable=False),
|
|
45
|
+
Column("created_at", DateTime(timezone=True), nullable=False),
|
|
46
|
+
Column("updated_at", DateTime(timezone=True), nullable=False),
|
|
47
|
+
Index("ix_hookrelay_events_status_next_retry", "status", "next_retry_at"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _row_to_event(row: Row[Any]) -> WebhookEvent:
|
|
52
|
+
return WebhookEvent(
|
|
53
|
+
id=row.id,
|
|
54
|
+
source=row.source,
|
|
55
|
+
idempotency_key=row.idempotency_key,
|
|
56
|
+
payload=row.payload,
|
|
57
|
+
headers=row.headers,
|
|
58
|
+
status=EventStatus(row.status),
|
|
59
|
+
attempts=row.attempts,
|
|
60
|
+
max_attempts=row.max_attempts,
|
|
61
|
+
last_error=row.last_error,
|
|
62
|
+
next_retry_at=row.next_retry_at,
|
|
63
|
+
created_at=row.created_at,
|
|
64
|
+
updated_at=row.updated_at,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PostgresBackend(Backend):
|
|
69
|
+
"""Postgres-backed implementation, safe for multiple concurrent workers.
|
|
70
|
+
|
|
71
|
+
Requires the `hookrelay_events` table to exist; call `init_schema()` once
|
|
72
|
+
(e.g. at application startup) or manage it through your own Alembic migrations
|
|
73
|
+
using the `hookrelay.backends.postgres.metadata` object.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, engine: AsyncEngine, retry_policy: RetryPolicy | None = None) -> None:
|
|
77
|
+
super().__init__(retry_policy)
|
|
78
|
+
self._engine = engine
|
|
79
|
+
|
|
80
|
+
async def init_schema(self) -> None:
|
|
81
|
+
async with self._engine.begin() as conn:
|
|
82
|
+
await conn.run_sync(metadata.create_all)
|
|
83
|
+
|
|
84
|
+
async def enqueue(self, event: WebhookEvent) -> bool:
|
|
85
|
+
stmt = pg_insert(events_table).values(
|
|
86
|
+
id=event.id,
|
|
87
|
+
source=event.source,
|
|
88
|
+
idempotency_key=event.idempotency_key,
|
|
89
|
+
payload=event.payload,
|
|
90
|
+
headers=event.headers,
|
|
91
|
+
status=event.status.value,
|
|
92
|
+
attempts=event.attempts,
|
|
93
|
+
max_attempts=event.max_attempts,
|
|
94
|
+
last_error=event.last_error,
|
|
95
|
+
next_retry_at=event.next_retry_at,
|
|
96
|
+
created_at=event.created_at,
|
|
97
|
+
updated_at=event.updated_at,
|
|
98
|
+
)
|
|
99
|
+
if event.idempotency_key is not None:
|
|
100
|
+
stmt = stmt.on_conflict_do_nothing(index_elements=["idempotency_key"])
|
|
101
|
+
async with self._engine.begin() as conn:
|
|
102
|
+
result = await conn.execute(stmt)
|
|
103
|
+
return result.rowcount > 0
|
|
104
|
+
|
|
105
|
+
async def claim_due(self, limit: int) -> list[WebhookEvent]:
|
|
106
|
+
now = datetime.now(timezone.utc)
|
|
107
|
+
claim_ids = (
|
|
108
|
+
select(events_table.c.id)
|
|
109
|
+
.where(events_table.c.status == EventStatus.PENDING.value)
|
|
110
|
+
.where(events_table.c.next_retry_at <= now)
|
|
111
|
+
.order_by(events_table.c.next_retry_at)
|
|
112
|
+
.limit(limit)
|
|
113
|
+
.with_for_update(skip_locked=True)
|
|
114
|
+
)
|
|
115
|
+
stmt = (
|
|
116
|
+
update(events_table)
|
|
117
|
+
.where(events_table.c.id.in_(claim_ids))
|
|
118
|
+
.values(status=EventStatus.PROCESSING.value, updated_at=now)
|
|
119
|
+
.returning(events_table)
|
|
120
|
+
)
|
|
121
|
+
async with self._engine.begin() as conn:
|
|
122
|
+
rows = (await conn.execute(stmt)).all()
|
|
123
|
+
return [_row_to_event(row) for row in rows]
|
|
124
|
+
|
|
125
|
+
async def ack(self, event_id: str) -> None:
|
|
126
|
+
stmt = (
|
|
127
|
+
update(events_table)
|
|
128
|
+
.where(events_table.c.id == event_id)
|
|
129
|
+
.values(status=EventStatus.SUCCESS.value, updated_at=datetime.now(timezone.utc))
|
|
130
|
+
)
|
|
131
|
+
async with self._engine.begin() as conn:
|
|
132
|
+
await conn.execute(stmt)
|
|
133
|
+
|
|
134
|
+
async def fail(self, event_id: str, error: str) -> WebhookEvent:
|
|
135
|
+
async with self._engine.begin() as conn:
|
|
136
|
+
row = (
|
|
137
|
+
await conn.execute(select(events_table).where(events_table.c.id == event_id))
|
|
138
|
+
).first()
|
|
139
|
+
if row is None:
|
|
140
|
+
raise EventNotFoundError(event_id)
|
|
141
|
+
updated_event = apply_failure(_row_to_event(row), error, self.retry_policy)
|
|
142
|
+
await conn.execute(
|
|
143
|
+
update(events_table)
|
|
144
|
+
.where(events_table.c.id == event_id)
|
|
145
|
+
.values(
|
|
146
|
+
status=updated_event.status.value,
|
|
147
|
+
attempts=updated_event.attempts,
|
|
148
|
+
last_error=updated_event.last_error,
|
|
149
|
+
next_retry_at=updated_event.next_retry_at,
|
|
150
|
+
updated_at=updated_event.updated_at,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return updated_event
|
|
154
|
+
|
|
155
|
+
async def list_dead_letters(self, limit: int = 100, offset: int = 0) -> list[WebhookEvent]:
|
|
156
|
+
stmt = (
|
|
157
|
+
select(events_table)
|
|
158
|
+
.where(events_table.c.status == EventStatus.DEAD_LETTER.value)
|
|
159
|
+
.order_by(events_table.c.updated_at.desc())
|
|
160
|
+
.limit(limit)
|
|
161
|
+
.offset(offset)
|
|
162
|
+
)
|
|
163
|
+
async with self._engine.connect() as conn:
|
|
164
|
+
rows = (await conn.execute(stmt)).all()
|
|
165
|
+
return [_row_to_event(row) for row in rows]
|
|
166
|
+
|
|
167
|
+
async def requeue_dead_letter(self, event_id: str) -> bool:
|
|
168
|
+
now = datetime.now(timezone.utc)
|
|
169
|
+
stmt = (
|
|
170
|
+
update(events_table)
|
|
171
|
+
.where(events_table.c.id == event_id)
|
|
172
|
+
.where(events_table.c.status == EventStatus.DEAD_LETTER.value)
|
|
173
|
+
.values(
|
|
174
|
+
status=EventStatus.PENDING.value,
|
|
175
|
+
attempts=0,
|
|
176
|
+
last_error=None,
|
|
177
|
+
next_retry_at=now,
|
|
178
|
+
updated_at=now,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
async with self._engine.begin() as conn:
|
|
182
|
+
result = await conn.execute(stmt)
|
|
183
|
+
return result.rowcount > 0
|
|
184
|
+
|
|
185
|
+
async def purge(
|
|
186
|
+
self,
|
|
187
|
+
older_than: datetime,
|
|
188
|
+
statuses: Sequence[EventStatus] = (EventStatus.SUCCESS, EventStatus.DEAD_LETTER),
|
|
189
|
+
) -> int:
|
|
190
|
+
"""Deletes events in one of `statuses` last updated before `older_than`.
|
|
191
|
+
Returns how many rows were removed. Call this periodically yourself
|
|
192
|
+
(hookrelay does not run it automatically) to keep a long-running
|
|
193
|
+
deployment's table from growing without bound."""
|
|
194
|
+
stmt = delete(events_table).where(
|
|
195
|
+
events_table.c.status.in_([s.value for s in statuses]),
|
|
196
|
+
events_table.c.updated_at < older_than,
|
|
197
|
+
)
|
|
198
|
+
async with self._engine.begin() as conn:
|
|
199
|
+
result = await conn.execute(stmt)
|
|
200
|
+
return result.rowcount
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from redis.asyncio import Redis
|
|
7
|
+
|
|
8
|
+
from hookrelay.backends.base import Backend, apply_failure
|
|
9
|
+
from hookrelay.exceptions import EventNotFoundError
|
|
10
|
+
from hookrelay.models import EventStatus, WebhookEvent
|
|
11
|
+
from hookrelay.retry import RetryPolicy
|
|
12
|
+
|
|
13
|
+
_IDEMPOTENCY_TTL_SECONDS = 7 * 24 * 60 * 60
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RedisBackend(Backend):
|
|
17
|
+
"""Redis-backed implementation.
|
|
18
|
+
|
|
19
|
+
Suitable for a small number of workers: claiming relies on `ZREM` being atomic
|
|
20
|
+
per member, which guarantees an event is only ever claimed once. A claimed event
|
|
21
|
+
is given a lease of `claim_lease_seconds`; if the worker that claimed it crashes
|
|
22
|
+
before acking or failing it, the event stays claimable again only after you call
|
|
23
|
+
`reap_stale_claims()`, which hookrelay does not do on its own. For very high
|
|
24
|
+
worker concurrency, prefer PostgresBackend.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
redis: Redis,
|
|
30
|
+
retry_policy: RetryPolicy | None = None,
|
|
31
|
+
namespace: str = "hookrelay",
|
|
32
|
+
claim_lease_seconds: float = 300.0,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(retry_policy)
|
|
35
|
+
self._redis = redis
|
|
36
|
+
self._namespace = namespace
|
|
37
|
+
self._claim_lease_seconds = claim_lease_seconds
|
|
38
|
+
|
|
39
|
+
def _event_key(self, event_id: str) -> str:
|
|
40
|
+
return f"{self._namespace}:event:{event_id}"
|
|
41
|
+
|
|
42
|
+
def _idempotency_key(self, key: str) -> str:
|
|
43
|
+
return f"{self._namespace}:idempotency:{key}"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def _schedule_key(self) -> str:
|
|
47
|
+
return f"{self._namespace}:schedule"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def _processing_key(self) -> str:
|
|
51
|
+
return f"{self._namespace}:processing"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def _dead_letter_key(self) -> str:
|
|
55
|
+
return f"{self._namespace}:dead_letter"
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _decode(raw: bytes | str) -> str:
|
|
59
|
+
return raw.decode() if isinstance(raw, bytes) else raw
|
|
60
|
+
|
|
61
|
+
async def _get_event(self, event_id: str) -> WebhookEvent | None:
|
|
62
|
+
data = await self._redis.get(self._event_key(event_id))
|
|
63
|
+
return WebhookEvent.model_validate_json(data) if data is not None else None
|
|
64
|
+
|
|
65
|
+
async def _save_event(self, event: WebhookEvent) -> None:
|
|
66
|
+
await self._redis.set(self._event_key(event.id), event.model_dump_json())
|
|
67
|
+
|
|
68
|
+
async def enqueue(self, event: WebhookEvent) -> bool:
|
|
69
|
+
if event.idempotency_key is not None:
|
|
70
|
+
is_new = await self._redis.set(
|
|
71
|
+
self._idempotency_key(event.idempotency_key),
|
|
72
|
+
event.id,
|
|
73
|
+
nx=True,
|
|
74
|
+
ex=_IDEMPOTENCY_TTL_SECONDS,
|
|
75
|
+
)
|
|
76
|
+
if not is_new:
|
|
77
|
+
return False
|
|
78
|
+
await self._save_event(event)
|
|
79
|
+
await self._redis.zadd(self._schedule_key, {event.id: event.next_retry_at.timestamp()})
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
async def claim_due(self, limit: int) -> list[WebhookEvent]:
|
|
83
|
+
now = datetime.now(timezone.utc)
|
|
84
|
+
candidate_ids = cast(
|
|
85
|
+
list[bytes | str],
|
|
86
|
+
await self._redis.zrangebyscore(
|
|
87
|
+
self._schedule_key, min=0, max=now.timestamp(), start=0, num=limit
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
claimed: list[WebhookEvent] = []
|
|
91
|
+
lease_expires_at = now.timestamp() + self._claim_lease_seconds
|
|
92
|
+
for raw_id in candidate_ids:
|
|
93
|
+
event_id = self._decode(raw_id)
|
|
94
|
+
removed = await self._redis.zrem(self._schedule_key, event_id)
|
|
95
|
+
if not removed:
|
|
96
|
+
continue # another worker claimed it between the read and this ZREM
|
|
97
|
+
event = await self._get_event(event_id)
|
|
98
|
+
if event is None:
|
|
99
|
+
continue
|
|
100
|
+
processing_event = event.model_copy(update={"status": EventStatus.PROCESSING})
|
|
101
|
+
await self._save_event(processing_event)
|
|
102
|
+
await self._redis.zadd(self._processing_key, {event_id: lease_expires_at})
|
|
103
|
+
claimed.append(processing_event)
|
|
104
|
+
return claimed
|
|
105
|
+
|
|
106
|
+
async def ack(self, event_id: str) -> None:
|
|
107
|
+
await self._redis.zrem(self._processing_key, event_id)
|
|
108
|
+
await self._redis.delete(self._event_key(event_id))
|
|
109
|
+
|
|
110
|
+
async def fail(self, event_id: str, error: str) -> WebhookEvent:
|
|
111
|
+
await self._redis.zrem(self._processing_key, event_id)
|
|
112
|
+
event = await self._get_event(event_id)
|
|
113
|
+
if event is None:
|
|
114
|
+
raise EventNotFoundError(event_id)
|
|
115
|
+
updated_event = apply_failure(event, error, self.retry_policy)
|
|
116
|
+
await self._save_event(updated_event)
|
|
117
|
+
if updated_event.status is EventStatus.DEAD_LETTER:
|
|
118
|
+
await self._redis.zadd(
|
|
119
|
+
self._dead_letter_key, {event_id: updated_event.updated_at.timestamp()}
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
await self._redis.zadd(
|
|
123
|
+
self._schedule_key, {event_id: updated_event.next_retry_at.timestamp()}
|
|
124
|
+
)
|
|
125
|
+
return updated_event
|
|
126
|
+
|
|
127
|
+
async def reap_stale_claims(self, limit: int = 100) -> int:
|
|
128
|
+
"""Requeues or dead-letters events whose processing lease expired without the
|
|
129
|
+
worker that claimed them acking or failing them, most commonly because that
|
|
130
|
+
worker crashed mid-handler. This reuses `fail()`, so a reaped event counts as
|
|
131
|
+
a failed attempt toward `max_attempts` like any other failure, instead of
|
|
132
|
+
being retried forever by a handler that keeps crashing the same way.
|
|
133
|
+
|
|
134
|
+
hookrelay does not call this on its own: schedule it yourself, for example
|
|
135
|
+
every `claim_lease_seconds / 2`, from whatever periodic task runner you
|
|
136
|
+
already use. Returns how many stale claims were reaped.
|
|
137
|
+
"""
|
|
138
|
+
now = datetime.now(timezone.utc).timestamp()
|
|
139
|
+
stale_ids = cast(
|
|
140
|
+
list[bytes | str],
|
|
141
|
+
await self._redis.zrangebyscore(
|
|
142
|
+
self._processing_key, min=0, max=now, start=0, num=limit
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
reaped = 0
|
|
146
|
+
error = "stale claim: lease expired before the worker acked or failed it"
|
|
147
|
+
for raw_id in stale_ids:
|
|
148
|
+
event_id = self._decode(raw_id)
|
|
149
|
+
removed = await self._redis.zrem(self._processing_key, event_id)
|
|
150
|
+
if not removed:
|
|
151
|
+
continue # acked, failed, or already reaped concurrently
|
|
152
|
+
try:
|
|
153
|
+
await self.fail(event_id, error)
|
|
154
|
+
except EventNotFoundError:
|
|
155
|
+
continue
|
|
156
|
+
reaped += 1
|
|
157
|
+
return reaped
|
|
158
|
+
|
|
159
|
+
async def list_dead_letters(self, limit: int = 100, offset: int = 0) -> list[WebhookEvent]:
|
|
160
|
+
end = offset + limit - 1
|
|
161
|
+
ids = cast(
|
|
162
|
+
list[bytes | str],
|
|
163
|
+
await self._redis.zrevrange(self._dead_letter_key, start=offset, end=end),
|
|
164
|
+
)
|
|
165
|
+
events = []
|
|
166
|
+
for raw_id in ids:
|
|
167
|
+
event_id = self._decode(raw_id)
|
|
168
|
+
event = await self._get_event(event_id)
|
|
169
|
+
if event is not None:
|
|
170
|
+
events.append(event)
|
|
171
|
+
return events
|
|
172
|
+
|
|
173
|
+
async def requeue_dead_letter(self, event_id: str) -> bool:
|
|
174
|
+
removed = await self._redis.zrem(self._dead_letter_key, event_id)
|
|
175
|
+
if not removed:
|
|
176
|
+
return False
|
|
177
|
+
event = await self._get_event(event_id)
|
|
178
|
+
if event is None:
|
|
179
|
+
return False
|
|
180
|
+
now = datetime.now(timezone.utc)
|
|
181
|
+
requeued_event = event.model_copy(
|
|
182
|
+
update={
|
|
183
|
+
"status": EventStatus.PENDING,
|
|
184
|
+
"attempts": 0,
|
|
185
|
+
"last_error": None,
|
|
186
|
+
"next_retry_at": now,
|
|
187
|
+
"updated_at": now,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
await self._save_event(requeued_event)
|
|
191
|
+
await self._redis.zadd(self._schedule_key, {event_id: now.timestamp()})
|
|
192
|
+
return True
|