eventhook 0.1.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.
- eventhook-0.1.0/.gitignore +22 -0
- eventhook-0.1.0/PKG-INFO +24 -0
- eventhook-0.1.0/eventhook/__init__.py +5 -0
- eventhook-0.1.0/eventhook/_backoff.py +20 -0
- eventhook-0.1.0/eventhook/_client.py +364 -0
- eventhook-0.1.0/eventhook/_dedup.py +29 -0
- eventhook-0.1.0/eventhook/_event.py +31 -0
- eventhook-0.1.0/eventhook/_proto/__init__.py +0 -0
- eventhook-0.1.0/eventhook/_proto/eventbridge/__init__.py +0 -0
- eventhook-0.1.0/eventhook/_proto/eventbridge/eventbridge_pb2.py +51 -0
- eventhook-0.1.0/eventhook/_proto/eventbridge/eventbridge_pb2_grpc.py +97 -0
- eventhook-0.1.0/eventhook/py.typed +0 -0
- eventhook-0.1.0/pyproject.toml +56 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Build artifacts
|
|
2
|
+
dist/
|
|
3
|
+
*.egg-info/
|
|
4
|
+
|
|
5
|
+
# Virtual environments
|
|
6
|
+
.venv/
|
|
7
|
+
|
|
8
|
+
# Python bytecode
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.pyc
|
|
11
|
+
*.pyo
|
|
12
|
+
|
|
13
|
+
# Test and debug artifacts
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
FAILURE_ANALYSIS.md
|
|
16
|
+
*.log
|
|
17
|
+
|
|
18
|
+
# Generated proto stubs (regenerate with generate_proto.sh)
|
|
19
|
+
eventhook/_proto/
|
|
20
|
+
|
|
21
|
+
# Packaging lock file (not for libraries)
|
|
22
|
+
uv.lock
|
eventhook-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eventhook
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Receive provider events in your app without exposing a public endpoint
|
|
5
|
+
Project-URL: Homepage, https://eventhook.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/iyifr/eventhook
|
|
7
|
+
Project-URL: Issues, https://github.com/iyifr/eventhook/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: event-driven,events,grpc,real-time,webhooks
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Internet
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: cachetools<6,>=5.0
|
|
23
|
+
Requires-Dist: grpcio<3,>=1.60.0
|
|
24
|
+
Requires-Dist: protobuf<7,>=6.33.5
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExponentialBackoff:
|
|
7
|
+
"""Full-jitter exponential backoff — never hammers the gateway on reconnect."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, base: float = 1.0, cap: float = 60.0) -> None:
|
|
10
|
+
self._base = base
|
|
11
|
+
self._cap = cap
|
|
12
|
+
self._attempt = 0
|
|
13
|
+
|
|
14
|
+
def next(self) -> float:
|
|
15
|
+
ceiling = min(self._cap, self._base * (2 ** self._attempt))
|
|
16
|
+
self._attempt += 1
|
|
17
|
+
return random.uniform(0, ceiling) # full jitter
|
|
18
|
+
|
|
19
|
+
def reset(self) -> None:
|
|
20
|
+
self._attempt = 0
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
import grpc
|
|
13
|
+
import grpc.aio
|
|
14
|
+
|
|
15
|
+
from ._backoff import ExponentialBackoff
|
|
16
|
+
from ._dedup import DedupCache
|
|
17
|
+
from ._event import Ack, Event
|
|
18
|
+
from ._proto.eventbridge import eventbridge_pb2 as pb
|
|
19
|
+
from ._proto.eventbridge import eventbridge_pb2_grpc as pb_grpc
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("eventhook")
|
|
22
|
+
|
|
23
|
+
# Pre-resolved once at import time — avoids a dict alloc + proto enum lookup per event.
|
|
24
|
+
_ACK_STATUS = {
|
|
25
|
+
Ack.SUCCESS: pb.Ack.Status.Value("DELIVERED"),
|
|
26
|
+
Ack.RETRY: pb.Ack.Status.Value("RETRYING"),
|
|
27
|
+
Ack.FAILED: pb.Ack.Status.Value("DEAD_LETTERED"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Keep the TCP connection alive through idle periods (long-running handlers,
|
|
31
|
+
# silent stretches between events). Without this, NATs and load balancers
|
|
32
|
+
# silently drop the connection and the stream appears open while being dead.
|
|
33
|
+
_KEEPALIVE_OPTIONS = [
|
|
34
|
+
("grpc.keepalive_time_ms", 20_000),
|
|
35
|
+
("grpc.keepalive_timeout_ms", 5_000),
|
|
36
|
+
("grpc.keepalive_permit_without_calls", True),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EventHooks:
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
provider_id: str,
|
|
45
|
+
app_name: str,
|
|
46
|
+
api_key: str,
|
|
47
|
+
gateway_url: str = "localhost:50051",
|
|
48
|
+
tls: bool = False,
|
|
49
|
+
max_retries: int = 3,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._provider_id = provider_id
|
|
52
|
+
self._app_name = app_name
|
|
53
|
+
self._api_key = api_key
|
|
54
|
+
self._gateway_url = gateway_url
|
|
55
|
+
self._tls = tls
|
|
56
|
+
self._max_retries = max_retries
|
|
57
|
+
|
|
58
|
+
self._handlers: dict[str, Callable] = {}
|
|
59
|
+
self._wildcards: list[
|
|
60
|
+
tuple[str, Callable]
|
|
61
|
+
] = [] # (prefix, handler) pre-sliced at registration
|
|
62
|
+
self._dedup = DedupCache()
|
|
63
|
+
self._backoff = ExponentialBackoff()
|
|
64
|
+
self._running = False
|
|
65
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
66
|
+
self._task: asyncio.Task | None = None
|
|
67
|
+
# Derived once from the raw api_key — mirrors what the ingest service stores at provisioning.
|
|
68
|
+
# Used to verify every event's HMAC before the handler is invoked.
|
|
69
|
+
self._signing_key: bytes = hmac.new(
|
|
70
|
+
api_key.encode(), b"eventhook-signing-v1", hashlib.sha256
|
|
71
|
+
).digest()
|
|
72
|
+
|
|
73
|
+
# ── Handler registration ──────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def on(self, event_type: str) -> Callable:
|
|
76
|
+
"""
|
|
77
|
+
Register a handler for an event type. Supports exact match and
|
|
78
|
+
prefix wildcard (e.g. "invoice.*").
|
|
79
|
+
|
|
80
|
+
The decorated function may be sync or async. Sync handlers run in a
|
|
81
|
+
thread pool so they never block the event loop.
|
|
82
|
+
|
|
83
|
+
@hooks.on("payment.succeeded")
|
|
84
|
+
def handle(event: Event) -> Ack:
|
|
85
|
+
...
|
|
86
|
+
return Ack.SUCCESS
|
|
87
|
+
|
|
88
|
+
@hooks.on("invoice.*")
|
|
89
|
+
async def handle(event: Event) -> Ack:
|
|
90
|
+
await some_coroutine()
|
|
91
|
+
return Ack.RETRY
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def decorator(fn: Callable) -> Callable:
|
|
95
|
+
if event_type.endswith(".*"):
|
|
96
|
+
self._wildcards.append((event_type[:-1], fn))
|
|
97
|
+
else:
|
|
98
|
+
self._handlers[event_type] = fn
|
|
99
|
+
return fn
|
|
100
|
+
|
|
101
|
+
return decorator
|
|
102
|
+
|
|
103
|
+
# ── Entry points ──────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def run(self) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Blocking entry point — creates its own event loop.
|
|
108
|
+
Use this for standalone scripts and Django management commands.
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
hooks.run()
|
|
112
|
+
"""
|
|
113
|
+
loop = asyncio.new_event_loop()
|
|
114
|
+
asyncio.set_event_loop(loop)
|
|
115
|
+
self._run_loop(loop)
|
|
116
|
+
|
|
117
|
+
def run_in_thread(self) -> threading.Thread:
|
|
118
|
+
"""
|
|
119
|
+
Starts the agent in a daemon thread with its own event loop.
|
|
120
|
+
Returns immediately. Use this for Flask and sync Django in-process.
|
|
121
|
+
|
|
122
|
+
hooks.run_in_thread()
|
|
123
|
+
app.run(port=8000)
|
|
124
|
+
|
|
125
|
+
Call stop() then join() the returned thread to ensure the session is
|
|
126
|
+
fully torn down before starting another one on the same credentials.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def _target():
|
|
130
|
+
loop = asyncio.new_event_loop()
|
|
131
|
+
asyncio.set_event_loop(loop)
|
|
132
|
+
self._run_loop(loop)
|
|
133
|
+
|
|
134
|
+
t = threading.Thread(target=_target, daemon=True, name="eventhook")
|
|
135
|
+
t.start()
|
|
136
|
+
return t
|
|
137
|
+
|
|
138
|
+
def _run_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
139
|
+
"""Run start() on loop, then drain any tasks left pending by gRPC
|
|
140
|
+
internals before closing — mirrors what asyncio.run() does internally.
|
|
141
|
+
|
|
142
|
+
Without the drain step, gRPC's _AioCall._handle_status_once_received
|
|
143
|
+
tasks outlive the stream cancellation and emit 'Task was destroyed but
|
|
144
|
+
it is pending!' when the loop closes.
|
|
145
|
+
"""
|
|
146
|
+
self._loop = loop
|
|
147
|
+
try:
|
|
148
|
+
loop.run_until_complete(self.start())
|
|
149
|
+
except asyncio.CancelledError:
|
|
150
|
+
pass
|
|
151
|
+
finally:
|
|
152
|
+
try:
|
|
153
|
+
pending = asyncio.all_tasks(loop)
|
|
154
|
+
if pending:
|
|
155
|
+
for t in pending:
|
|
156
|
+
t.cancel()
|
|
157
|
+
loop.run_until_complete(
|
|
158
|
+
asyncio.gather(*pending, return_exceptions=True)
|
|
159
|
+
)
|
|
160
|
+
finally:
|
|
161
|
+
self._loop = None
|
|
162
|
+
loop.close()
|
|
163
|
+
|
|
164
|
+
async def start(self) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Async entry point — runs inside an existing event loop.
|
|
167
|
+
Use this for FastAPI lifespan, Quart, and other async frameworks.
|
|
168
|
+
|
|
169
|
+
# FastAPI
|
|
170
|
+
@asynccontextmanager
|
|
171
|
+
async def lifespan(app):
|
|
172
|
+
task = asyncio.create_task(hooks.start())
|
|
173
|
+
yield
|
|
174
|
+
hooks.stop()
|
|
175
|
+
await task
|
|
176
|
+
"""
|
|
177
|
+
self._running = True
|
|
178
|
+
self._task = asyncio.current_task()
|
|
179
|
+
while self._running:
|
|
180
|
+
try:
|
|
181
|
+
await self._run_session()
|
|
182
|
+
except asyncio.CancelledError:
|
|
183
|
+
break
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
if not self._running:
|
|
186
|
+
break
|
|
187
|
+
wait = self._backoff.next()
|
|
188
|
+
logger.warning("Disconnected (%s) — reconnecting in %.1fs", exc, wait)
|
|
189
|
+
await asyncio.sleep(wait)
|
|
190
|
+
|
|
191
|
+
def stop(self) -> None:
|
|
192
|
+
"""Stop the agent. Cancels the running session immediately."""
|
|
193
|
+
self._running = False
|
|
194
|
+
loop, task = self._loop, self._task
|
|
195
|
+
if loop is not None and task is not None:
|
|
196
|
+
loop.call_soon_threadsafe(task.cancel)
|
|
197
|
+
|
|
198
|
+
# ── Session lifecycle ─────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
async def _run_session(self) -> None:
|
|
201
|
+
creds = (
|
|
202
|
+
grpc.ssl_channel_credentials()
|
|
203
|
+
if self._tls
|
|
204
|
+
else grpc.local_channel_credentials()
|
|
205
|
+
)
|
|
206
|
+
channel_options = _KEEPALIVE_OPTIONS
|
|
207
|
+
|
|
208
|
+
async with grpc.aio.secure_channel(
|
|
209
|
+
self._gateway_url, creds, options=channel_options
|
|
210
|
+
) as channel:
|
|
211
|
+
stub = pb_grpc.EventBridgeStub(channel)
|
|
212
|
+
metadata = [
|
|
213
|
+
("api_key", self._api_key),
|
|
214
|
+
("provider_id", self._provider_id),
|
|
215
|
+
("app_name", self._app_name),
|
|
216
|
+
]
|
|
217
|
+
stream = stub.Subscribe(metadata=metadata)
|
|
218
|
+
|
|
219
|
+
all_subscribed = list(self._handlers.keys()) + [
|
|
220
|
+
p + "*" for p, _ in self._wildcards
|
|
221
|
+
]
|
|
222
|
+
await stream.write(
|
|
223
|
+
pb.ClientMessage(
|
|
224
|
+
subscribe=pb.SubscribeRequest(event_types=all_subscribed)
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
self._backoff.reset()
|
|
229
|
+
logger.info(
|
|
230
|
+
"Connected to %s — subscribed to %s",
|
|
231
|
+
self._gateway_url,
|
|
232
|
+
all_subscribed or ["(all)"],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
while True:
|
|
236
|
+
proto_event = await stream.read()
|
|
237
|
+
if proto_event == grpc.aio.EOF:
|
|
238
|
+
logger.info("Stream closed by gateway")
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
sig_header = proto_event.metadata.get("eh-sig", "")
|
|
242
|
+
if not self._verify_signature(sig_header, proto_event.payload):
|
|
243
|
+
logger.warning(
|
|
244
|
+
"Signature verification failed for event %s — dead-lettering",
|
|
245
|
+
proto_event.event_id,
|
|
246
|
+
)
|
|
247
|
+
await stream.write(
|
|
248
|
+
pb.ClientMessage(
|
|
249
|
+
ack=pb.Ack(
|
|
250
|
+
event_id=proto_event.event_id,
|
|
251
|
+
status=pb.Ack.Status.Value("DEAD_LETTERED"),
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
event = Event._from_proto(proto_event)
|
|
258
|
+
ack = await self._dispatch(event)
|
|
259
|
+
proto_status = _ACK_STATUS[ack]
|
|
260
|
+
|
|
261
|
+
await stream.write(
|
|
262
|
+
pb.ClientMessage(
|
|
263
|
+
ack=pb.Ack(
|
|
264
|
+
event_id=event.event_id,
|
|
265
|
+
status=proto_status,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Signature verification
|
|
271
|
+
def _verify_signature(self, header: str, payload: bytes) -> bool:
|
|
272
|
+
"""Return True only if header carries a fresh, valid HMAC for payload."""
|
|
273
|
+
if not header:
|
|
274
|
+
return False
|
|
275
|
+
try:
|
|
276
|
+
parts = dict(kv.split("=", 1) for kv in header.split(","))
|
|
277
|
+
ts = int(parts["t"])
|
|
278
|
+
v1 = parts["v1"]
|
|
279
|
+
except (KeyError, ValueError):
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
# Reject signatures outside the 5-minute replay window.
|
|
283
|
+
# Use the timestamp from the header (not event.timestamp) because it is
|
|
284
|
+
# the value that was included in the signed content.
|
|
285
|
+
age_ms = time.time() * 1000 - ts
|
|
286
|
+
if not (0 <= age_ms <= 5 * 60 * 1000):
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
to_sign = f"{ts}".encode() + b"." + payload
|
|
290
|
+
expected = hmac.new(self._signing_key, to_sign, hashlib.sha256).hexdigest()
|
|
291
|
+
return hmac.compare_digest(expected, v1)
|
|
292
|
+
|
|
293
|
+
# Execute handler for event
|
|
294
|
+
|
|
295
|
+
async def _dispatch(self, event: Event) -> Ack:
|
|
296
|
+
if self._dedup.seen(event.event_id):
|
|
297
|
+
logger.debug("Duplicate event %s — skipping", event.event_id)
|
|
298
|
+
return Ack.SUCCESS
|
|
299
|
+
|
|
300
|
+
handler = self._match_handler(event.event_type)
|
|
301
|
+
if handler is None:
|
|
302
|
+
logger.warning(
|
|
303
|
+
"No handler for '%s' (event %s) — dead lettering",
|
|
304
|
+
event.event_type,
|
|
305
|
+
event.event_id,
|
|
306
|
+
)
|
|
307
|
+
return Ack.FAILED
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
result = (
|
|
311
|
+
await handler(event)
|
|
312
|
+
if inspect.iscoroutinefunction(handler)
|
|
313
|
+
else await asyncio.get_running_loop().run_in_executor(
|
|
314
|
+
None, handler, event
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
if not isinstance(result, Ack):
|
|
318
|
+
raise TypeError(f"Handler must return Ack, got {type(result).__name__}")
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
logger.error(
|
|
321
|
+
"Handler for '%s' raised %s: %s",
|
|
322
|
+
event.event_type,
|
|
323
|
+
type(exc).__name__,
|
|
324
|
+
exc,
|
|
325
|
+
)
|
|
326
|
+
return self._schedule_retry(event)
|
|
327
|
+
|
|
328
|
+
if result == Ack.SUCCESS:
|
|
329
|
+
return Ack.SUCCESS
|
|
330
|
+
|
|
331
|
+
if result == Ack.FAILED:
|
|
332
|
+
return Ack.FAILED
|
|
333
|
+
|
|
334
|
+
# Ack.RETRY returned directly (internal path) — same retry budget as exception.
|
|
335
|
+
return self._schedule_retry(event)
|
|
336
|
+
|
|
337
|
+
def _schedule_retry(self, event: Event) -> Ack:
|
|
338
|
+
# Durable retry tracking
|
|
339
|
+
attempt = int(event.metadata.get("eh-retry-count", "0"))
|
|
340
|
+
if attempt < self._max_retries:
|
|
341
|
+
self._dedup.evict(
|
|
342
|
+
event.event_id
|
|
343
|
+
) # allow the retry delivery to reach the handler
|
|
344
|
+
logger.warning(
|
|
345
|
+
"Handler failed for '%s' (attempt %d/%d) — retrying",
|
|
346
|
+
event.event_type,
|
|
347
|
+
attempt + 1,
|
|
348
|
+
self._max_retries,
|
|
349
|
+
)
|
|
350
|
+
return Ack.RETRY
|
|
351
|
+
logger.error(
|
|
352
|
+
"Handler failed for '%s' after %d attempts — dead lettering",
|
|
353
|
+
event.event_type,
|
|
354
|
+
attempt + 1,
|
|
355
|
+
)
|
|
356
|
+
return Ack.FAILED
|
|
357
|
+
|
|
358
|
+
def _match_handler(self, event_type: str) -> Callable | None:
|
|
359
|
+
if event_type in self._handlers:
|
|
360
|
+
return self._handlers[event_type]
|
|
361
|
+
for prefix, handler in self._wildcards:
|
|
362
|
+
if event_type.startswith(prefix):
|
|
363
|
+
return handler
|
|
364
|
+
return None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from cachetools import TTLCache
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DedupCache:
|
|
7
|
+
"""
|
|
8
|
+
Deduplicates events by event_id using an LRU+TTL cache.
|
|
9
|
+
Protects against at-least-once redelivery reaching the handler twice.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
maxsize: int = 2000,
|
|
15
|
+
ttl: float = 300.0,
|
|
16
|
+
timer: Callable[[], float] = time.monotonic,
|
|
17
|
+
) -> None:
|
|
18
|
+
self._cache: TTLCache = TTLCache(maxsize=maxsize, ttl=ttl, timer=timer)
|
|
19
|
+
|
|
20
|
+
def seen(self, event_id: str) -> bool:
|
|
21
|
+
"""Return True if this event_id was already seen (duplicate). Records it if not."""
|
|
22
|
+
if event_id in self._cache:
|
|
23
|
+
return True
|
|
24
|
+
self._cache[event_id] = True
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
def evict(self, event_id: str) -> None:
|
|
28
|
+
"""Remove event_id so a retry can reach the handler."""
|
|
29
|
+
self._cache.pop(event_id, None)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Event:
|
|
11
|
+
event_id: str
|
|
12
|
+
event_type: str
|
|
13
|
+
payload: Any # decoded from JSON bytes
|
|
14
|
+
timestamp: int # unix ms
|
|
15
|
+
metadata: dict[str, str]
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def _from_proto(cls, pb) -> "Event":
|
|
19
|
+
return cls(
|
|
20
|
+
event_id=pb.event_id,
|
|
21
|
+
event_type=pb.event_type,
|
|
22
|
+
payload=json.loads(pb.payload) if pb.payload else None,
|
|
23
|
+
timestamp=pb.timestamp,
|
|
24
|
+
metadata=dict(pb.metadata),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Ack(IntEnum):
|
|
29
|
+
SUCCESS = 0 # handler completed — event acknowledged
|
|
30
|
+
FAILED = 1 # permanent failure — event dead-lettered, no retry
|
|
31
|
+
RETRY = 2 # internal: emitted by the SDK daemon on exception/stall, not for handler authors
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# NO CHECKED-IN PROTOBUF GENCODE
|
|
4
|
+
# source: eventbridge/eventbridge.proto
|
|
5
|
+
# Protobuf Python Version: 6.33.5
|
|
6
|
+
"""Generated protocol buffer code."""
|
|
7
|
+
from google.protobuf import descriptor as _descriptor
|
|
8
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
9
|
+
from google.protobuf import runtime_version as _runtime_version
|
|
10
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
11
|
+
from google.protobuf.internal import builder as _builder
|
|
12
|
+
_runtime_version.ValidateProtobufRuntimeVersion(
|
|
13
|
+
_runtime_version.Domain.PUBLIC,
|
|
14
|
+
6,
|
|
15
|
+
33,
|
|
16
|
+
5,
|
|
17
|
+
'',
|
|
18
|
+
'eventbridge/eventbridge.proto'
|
|
19
|
+
)
|
|
20
|
+
# @@protoc_insertion_point(imports)
|
|
21
|
+
|
|
22
|
+
_sym_db = _symbol_database.Default()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1d\x65ventbridge/eventbridge.proto\x12\x0b\x65ventbridge\"l\n\rClientMessage\x12\x32\n\tsubscribe\x18\x01 \x01(\x0b\x32\x1d.eventbridge.SubscribeRequestH\x00\x12\x1f\n\x03\x61\x63k\x18\x02 \x01(\x0b\x32\x10.eventbridge.AckH\x00\x42\x06\n\x04\x62ody\"\'\n\x10SubscribeRequest\x12\x13\n\x0b\x65vent_types\x18\x01 \x03(\t\"\xb6\x01\n\x05\x45vent\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\t\x12\x12\n\nevent_type\x18\x02 \x01(\t\x12\x0f\n\x07payload\x18\x03 \x01(\x0c\x12\x11\n\ttimestamp\x18\x04 \x01(\x03\x12\x32\n\x08metadata\x18\x05 \x03(\x0b\x32 .eventbridge.Event.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"z\n\x03\x41\x63k\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\t\x12\'\n\x06status\x18\x02 \x01(\x0e\x32\x17.eventbridge.Ack.Status\"8\n\x06Status\x12\r\n\tDELIVERED\x10\x00\x12\x0c\n\x08RETRYING\x10\x01\x12\x11\n\rDEAD_LETTERED\x10\x02\x32N\n\x0b\x45ventBridge\x12?\n\tSubscribe\x12\x1a.eventbridge.ClientMessage\x1a\x12.eventbridge.Event(\x01\x30\x01\x42\x35Z3github.com/eventhook/stream-gateway/gen/eventbridgeb\x06proto3')
|
|
28
|
+
|
|
29
|
+
_globals = globals()
|
|
30
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
31
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'eventbridge.eventbridge_pb2', _globals)
|
|
32
|
+
if not _descriptor._USE_C_DESCRIPTORS:
|
|
33
|
+
_globals['DESCRIPTOR']._loaded_options = None
|
|
34
|
+
_globals['DESCRIPTOR']._serialized_options = b'Z3github.com/eventhook/stream-gateway/gen/eventbridge'
|
|
35
|
+
_globals['_EVENT_METADATAENTRY']._loaded_options = None
|
|
36
|
+
_globals['_EVENT_METADATAENTRY']._serialized_options = b'8\001'
|
|
37
|
+
_globals['_CLIENTMESSAGE']._serialized_start=46
|
|
38
|
+
_globals['_CLIENTMESSAGE']._serialized_end=154
|
|
39
|
+
_globals['_SUBSCRIBEREQUEST']._serialized_start=156
|
|
40
|
+
_globals['_SUBSCRIBEREQUEST']._serialized_end=195
|
|
41
|
+
_globals['_EVENT']._serialized_start=198
|
|
42
|
+
_globals['_EVENT']._serialized_end=380
|
|
43
|
+
_globals['_EVENT_METADATAENTRY']._serialized_start=333
|
|
44
|
+
_globals['_EVENT_METADATAENTRY']._serialized_end=380
|
|
45
|
+
_globals['_ACK']._serialized_start=382
|
|
46
|
+
_globals['_ACK']._serialized_end=504
|
|
47
|
+
_globals['_ACK_STATUS']._serialized_start=448
|
|
48
|
+
_globals['_ACK_STATUS']._serialized_end=504
|
|
49
|
+
_globals['_EVENTBRIDGE']._serialized_start=506
|
|
50
|
+
_globals['_EVENTBRIDGE']._serialized_end=584
|
|
51
|
+
# @@protoc_insertion_point(module_scope)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
|
2
|
+
"""Client and server classes corresponding to protobuf-defined services."""
|
|
3
|
+
import grpc
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
from . import eventbridge_pb2 as eventbridge_dot_eventbridge__pb2
|
|
7
|
+
|
|
8
|
+
GRPC_GENERATED_VERSION = '1.81.0'
|
|
9
|
+
GRPC_VERSION = grpc.__version__
|
|
10
|
+
_version_not_supported = False
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from grpc._utilities import first_version_is_lower
|
|
14
|
+
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
|
|
15
|
+
except ImportError:
|
|
16
|
+
_version_not_supported = True
|
|
17
|
+
|
|
18
|
+
if _version_not_supported:
|
|
19
|
+
raise RuntimeError(
|
|
20
|
+
f'The grpc package installed is at version {GRPC_VERSION},'
|
|
21
|
+
+ ' but the generated code in eventbridge/eventbridge_pb2_grpc.py depends on'
|
|
22
|
+
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
|
|
23
|
+
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
|
|
24
|
+
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EventBridgeStub:
|
|
29
|
+
"""Missing associated documentation comment in .proto file."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, channel):
|
|
32
|
+
"""Constructor.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
channel: A grpc.Channel.
|
|
36
|
+
"""
|
|
37
|
+
self.Subscribe = channel.stream_stream(
|
|
38
|
+
'/eventbridge.EventBridge/Subscribe',
|
|
39
|
+
request_serializer=eventbridge_dot_eventbridge__pb2.ClientMessage.SerializeToString,
|
|
40
|
+
response_deserializer=eventbridge_dot_eventbridge__pb2.Event.FromString,
|
|
41
|
+
_registered_method=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EventBridgeServicer:
|
|
45
|
+
"""Missing associated documentation comment in .proto file."""
|
|
46
|
+
|
|
47
|
+
def Subscribe(self, request_iterator, context):
|
|
48
|
+
"""Missing associated documentation comment in .proto file."""
|
|
49
|
+
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
|
50
|
+
context.set_details('Method not implemented!')
|
|
51
|
+
raise NotImplementedError('Method not implemented!')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def add_EventBridgeServicer_to_server(servicer, server):
|
|
55
|
+
rpc_method_handlers = {
|
|
56
|
+
'Subscribe': grpc.stream_stream_rpc_method_handler(
|
|
57
|
+
servicer.Subscribe,
|
|
58
|
+
request_deserializer=eventbridge_dot_eventbridge__pb2.ClientMessage.FromString,
|
|
59
|
+
response_serializer=eventbridge_dot_eventbridge__pb2.Event.SerializeToString,
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
generic_handler = grpc.method_handlers_generic_handler(
|
|
63
|
+
'eventbridge.EventBridge', rpc_method_handlers)
|
|
64
|
+
server.add_generic_rpc_handlers((generic_handler,))
|
|
65
|
+
server.add_registered_method_handlers('eventbridge.EventBridge', rpc_method_handlers)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# This class is part of an EXPERIMENTAL API.
|
|
69
|
+
class EventBridge:
|
|
70
|
+
"""Missing associated documentation comment in .proto file."""
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def Subscribe(request_iterator,
|
|
74
|
+
target,
|
|
75
|
+
options=(),
|
|
76
|
+
channel_credentials=None,
|
|
77
|
+
call_credentials=None,
|
|
78
|
+
insecure=False,
|
|
79
|
+
compression=None,
|
|
80
|
+
wait_for_ready=None,
|
|
81
|
+
timeout=None,
|
|
82
|
+
metadata=None):
|
|
83
|
+
return grpc.experimental.stream_stream(
|
|
84
|
+
request_iterator,
|
|
85
|
+
target,
|
|
86
|
+
'/eventbridge.EventBridge/Subscribe',
|
|
87
|
+
eventbridge_dot_eventbridge__pb2.ClientMessage.SerializeToString,
|
|
88
|
+
eventbridge_dot_eventbridge__pb2.Event.FromString,
|
|
89
|
+
options,
|
|
90
|
+
channel_credentials,
|
|
91
|
+
insecure,
|
|
92
|
+
call_credentials,
|
|
93
|
+
compression,
|
|
94
|
+
wait_for_ready,
|
|
95
|
+
timeout,
|
|
96
|
+
metadata,
|
|
97
|
+
_registered_method=True)
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "eventhook"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Receive provider events in your app without exposing a public endpoint"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
keywords = ["webhooks", "events", "grpc", "real-time", "event-driven"]
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"License :: OSI Approved :: MIT License",
|
|
12
|
+
"Operating System :: OS Independent",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Topic :: Internet",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"grpcio>=1.60.0,<3",
|
|
23
|
+
"protobuf>=6.33.5,<7",
|
|
24
|
+
"cachetools>=5.0,<6",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://eventhook.dev"
|
|
29
|
+
Repository = "https://github.com/iyifr/eventhook"
|
|
30
|
+
Issues = "https://github.com/iyifr/eventhook/issues"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"grpcio-tools>=1.60.0,<3",
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-asyncio>=0.23",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build]
|
|
44
|
+
artifacts = ["eventhook/_proto/**"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
include = ["eventhook/**"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.sdist]
|
|
50
|
+
include = [
|
|
51
|
+
"/eventhook",
|
|
52
|
+
"/pyproject.toml",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
asyncio_mode = "auto"
|