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 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,4 @@
1
+ from hookrelay.backends.base import Backend
2
+ from hookrelay.backends.memory import MemoryBackend
3
+
4
+ __all__ = ["Backend", "MemoryBackend"]
@@ -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