hooka-relay-python 1.0.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.
- hooka_relay_python-1.0.0/LICENSE +21 -0
- hooka_relay_python-1.0.0/PKG-INFO +106 -0
- hooka_relay_python-1.0.0/README.md +93 -0
- hooka_relay_python-1.0.0/pyproject.toml +19 -0
- hooka_relay_python-1.0.0/setup.cfg +4 -0
- hooka_relay_python-1.0.0/src/hooka_relay/__init__.py +67 -0
- hooka_relay_python-1.0.0/src/hooka_relay/generated.py +31 -0
- hooka_relay_python-1.0.0/src/hooka_relay/py.typed +0 -0
- hooka_relay_python-1.0.0/src/hooka_relay_python.egg-info/PKG-INFO +106 -0
- hooka_relay_python-1.0.0/src/hooka_relay_python.egg-info/SOURCES.txt +12 -0
- hooka_relay_python-1.0.0/src/hooka_relay_python.egg-info/dependency_links.txt +1 -0
- hooka_relay_python-1.0.0/src/hooka_relay_python.egg-info/requires.txt +1 -0
- hooka_relay_python-1.0.0/src/hooka_relay_python.egg-info/top_level.txt +1 -0
- hooka_relay_python-1.0.0/tests/test_client.py +69 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wael Fezari
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hooka-relay-python
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Typed Hooka Relay event client and Standard Webhooks verification
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Documentation, https://hooka-relay.vercel.app/docs
|
|
7
|
+
Project-URL: Source, https://github.com/wauul/hooka-relay/tree/master/packages/python
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: standardwebhooks==1.1.0
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# hooka-relay-python
|
|
15
|
+
|
|
16
|
+
Python 3.11+ client for [Hooka Relay](https://hooka-relay.vercel.app/docs#api-reference).
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pip install hooka-relay-python
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import os
|
|
24
|
+
from hooka_relay import HookaRelay
|
|
25
|
+
relay = HookaRelay(os.environ["HOOKA_API_KEY"])
|
|
26
|
+
event = relay.send_event({
|
|
27
|
+
"type": "order.created", "payload": {"orderId": "123"},
|
|
28
|
+
"idempotencyKey": "order-123-created",
|
|
29
|
+
})
|
|
30
|
+
print(event["id"])
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Use an ingest-only or existing unscoped application key. Optional `base_url` and `timeout` (seconds, default 30) configure the client. Remote URLs require HTTPS; redirects are rejected to avoid credential forwarding. There are no implicit retries. Retry ambiguous network failures with the **same explicit idempotencyKey**; omitted keys are generated by the server, so resending without one can create another event.
|
|
34
|
+
|
|
35
|
+
`HookaError` exposes `status`, parsed `body`, and `retry_after`. Rate limits return 429, oversized requests 413, and schema failures 400 with `failures: [{path, message}]`. Payload limits: 256 KiB / JSON depth 32. TypedDict models are generated from the repository's OpenAPI contract; runtime server validation remains authoritative.
|
|
36
|
+
|
|
37
|
+
## Verify and queue, then drain
|
|
38
|
+
|
|
39
|
+
`verify_webhook(raw_body, headers, secret)` uses the Standard Webhooks reference library. It raises on invalid signatures, a changed ID/body, or timestamps outside five minutes. Pass raw bytes and the displayed `whsec_` secret. During rotation either old or new key verifies the dual signatures. Existing LEGACY endpoints must explicitly migrate after their receiver supports Standard Webhooks.
|
|
40
|
+
|
|
41
|
+
Minimal queue-and-drain receiver using the standard library (put a production HTTP server/reverse proxy in front of a real deployment). A bounded queue rejects overload with 503; accepted work is processed outside the request.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import os
|
|
45
|
+
import logging
|
|
46
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
47
|
+
from queue import Queue, Full
|
|
48
|
+
from threading import Thread
|
|
49
|
+
from hooka_relay import verify_webhook
|
|
50
|
+
|
|
51
|
+
queue = Queue(maxsize=1000)
|
|
52
|
+
|
|
53
|
+
def process_event(item):
|
|
54
|
+
print(item["id"], item["payload"])
|
|
55
|
+
|
|
56
|
+
def drain():
|
|
57
|
+
while True:
|
|
58
|
+
item = queue.get()
|
|
59
|
+
try:
|
|
60
|
+
process_event(item)
|
|
61
|
+
except Exception:
|
|
62
|
+
logging.exception("Persist failed item to your dead-letter store: %s", item["id"])
|
|
63
|
+
finally:
|
|
64
|
+
queue.task_done()
|
|
65
|
+
|
|
66
|
+
class Receiver(BaseHTTPRequestHandler):
|
|
67
|
+
def do_POST(self):
|
|
68
|
+
if self.path != "/webhook":
|
|
69
|
+
self.send_error(404)
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
length = int(self.headers.get("Content-Length", "-1"))
|
|
73
|
+
except ValueError:
|
|
74
|
+
self.send_error(400)
|
|
75
|
+
return
|
|
76
|
+
if length < 0 or self.headers.get("Transfer-Encoding"):
|
|
77
|
+
self.send_error(411)
|
|
78
|
+
return
|
|
79
|
+
if length > 262144:
|
|
80
|
+
self.send_error(413)
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
headers = {k: self.headers.get(k, "") for k in ("webhook-id", "webhook-timestamp", "webhook-signature")}
|
|
84
|
+
payload = verify_webhook(self.rfile.read(length), headers, os.environ["HOOKA_SIGNING_SECRET"])
|
|
85
|
+
except Exception:
|
|
86
|
+
self.send_error(400)
|
|
87
|
+
return
|
|
88
|
+
try:
|
|
89
|
+
queue.put_nowait({"id": headers["webhook-id"], "payload": payload})
|
|
90
|
+
except Full:
|
|
91
|
+
self.send_error(503)
|
|
92
|
+
return
|
|
93
|
+
self.send_response(202)
|
|
94
|
+
self.end_headers()
|
|
95
|
+
|
|
96
|
+
Thread(target=drain, daemon=True).start()
|
|
97
|
+
ThreadingHTTPServer(("127.0.0.1", 8080), Receiver).serve_forever()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The queue is **volatile**: a crash loses already acknowledged work. For production, persist to a durable inbox/queue before acknowledging. Atomically deduplicate the authenticated `webhook-id` with business changes. Persist processing failures for retry or dead-letter handling; a log entry is not durable recovery.
|
|
101
|
+
|
|
102
|
+
## Event ordering
|
|
103
|
+
|
|
104
|
+
Ordering is **not guaranteed across retries or replay generations**. The queue-and-drain example above separates acknowledgement from slow processing and isolates failures. It cannot restore producer order; apply per-entity sequence/version checks if required.
|
|
105
|
+
|
|
106
|
+
[Interactive API reference](https://hooka-relay.vercel.app/docs#api-reference) · [Security and migration](https://github.com/wauul/hooka-relay/blob/master/SECURITY.md)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# hooka-relay-python
|
|
2
|
+
|
|
3
|
+
Python 3.11+ client for [Hooka Relay](https://hooka-relay.vercel.app/docs#api-reference).
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install hooka-relay-python
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import os
|
|
11
|
+
from hooka_relay import HookaRelay
|
|
12
|
+
relay = HookaRelay(os.environ["HOOKA_API_KEY"])
|
|
13
|
+
event = relay.send_event({
|
|
14
|
+
"type": "order.created", "payload": {"orderId": "123"},
|
|
15
|
+
"idempotencyKey": "order-123-created",
|
|
16
|
+
})
|
|
17
|
+
print(event["id"])
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Use an ingest-only or existing unscoped application key. Optional `base_url` and `timeout` (seconds, default 30) configure the client. Remote URLs require HTTPS; redirects are rejected to avoid credential forwarding. There are no implicit retries. Retry ambiguous network failures with the **same explicit idempotencyKey**; omitted keys are generated by the server, so resending without one can create another event.
|
|
21
|
+
|
|
22
|
+
`HookaError` exposes `status`, parsed `body`, and `retry_after`. Rate limits return 429, oversized requests 413, and schema failures 400 with `failures: [{path, message}]`. Payload limits: 256 KiB / JSON depth 32. TypedDict models are generated from the repository's OpenAPI contract; runtime server validation remains authoritative.
|
|
23
|
+
|
|
24
|
+
## Verify and queue, then drain
|
|
25
|
+
|
|
26
|
+
`verify_webhook(raw_body, headers, secret)` uses the Standard Webhooks reference library. It raises on invalid signatures, a changed ID/body, or timestamps outside five minutes. Pass raw bytes and the displayed `whsec_` secret. During rotation either old or new key verifies the dual signatures. Existing LEGACY endpoints must explicitly migrate after their receiver supports Standard Webhooks.
|
|
27
|
+
|
|
28
|
+
Minimal queue-and-drain receiver using the standard library (put a production HTTP server/reverse proxy in front of a real deployment). A bounded queue rejects overload with 503; accepted work is processed outside the request.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import os
|
|
32
|
+
import logging
|
|
33
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
34
|
+
from queue import Queue, Full
|
|
35
|
+
from threading import Thread
|
|
36
|
+
from hooka_relay import verify_webhook
|
|
37
|
+
|
|
38
|
+
queue = Queue(maxsize=1000)
|
|
39
|
+
|
|
40
|
+
def process_event(item):
|
|
41
|
+
print(item["id"], item["payload"])
|
|
42
|
+
|
|
43
|
+
def drain():
|
|
44
|
+
while True:
|
|
45
|
+
item = queue.get()
|
|
46
|
+
try:
|
|
47
|
+
process_event(item)
|
|
48
|
+
except Exception:
|
|
49
|
+
logging.exception("Persist failed item to your dead-letter store: %s", item["id"])
|
|
50
|
+
finally:
|
|
51
|
+
queue.task_done()
|
|
52
|
+
|
|
53
|
+
class Receiver(BaseHTTPRequestHandler):
|
|
54
|
+
def do_POST(self):
|
|
55
|
+
if self.path != "/webhook":
|
|
56
|
+
self.send_error(404)
|
|
57
|
+
return
|
|
58
|
+
try:
|
|
59
|
+
length = int(self.headers.get("Content-Length", "-1"))
|
|
60
|
+
except ValueError:
|
|
61
|
+
self.send_error(400)
|
|
62
|
+
return
|
|
63
|
+
if length < 0 or self.headers.get("Transfer-Encoding"):
|
|
64
|
+
self.send_error(411)
|
|
65
|
+
return
|
|
66
|
+
if length > 262144:
|
|
67
|
+
self.send_error(413)
|
|
68
|
+
return
|
|
69
|
+
try:
|
|
70
|
+
headers = {k: self.headers.get(k, "") for k in ("webhook-id", "webhook-timestamp", "webhook-signature")}
|
|
71
|
+
payload = verify_webhook(self.rfile.read(length), headers, os.environ["HOOKA_SIGNING_SECRET"])
|
|
72
|
+
except Exception:
|
|
73
|
+
self.send_error(400)
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
queue.put_nowait({"id": headers["webhook-id"], "payload": payload})
|
|
77
|
+
except Full:
|
|
78
|
+
self.send_error(503)
|
|
79
|
+
return
|
|
80
|
+
self.send_response(202)
|
|
81
|
+
self.end_headers()
|
|
82
|
+
|
|
83
|
+
Thread(target=drain, daemon=True).start()
|
|
84
|
+
ThreadingHTTPServer(("127.0.0.1", 8080), Receiver).serve_forever()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The queue is **volatile**: a crash loses already acknowledged work. For production, persist to a durable inbox/queue before acknowledging. Atomically deduplicate the authenticated `webhook-id` with business changes. Persist processing failures for retry or dead-letter handling; a log entry is not durable recovery.
|
|
88
|
+
|
|
89
|
+
## Event ordering
|
|
90
|
+
|
|
91
|
+
Ordering is **not guaranteed across retries or replay generations**. The queue-and-drain example above separates acknowledgement from slow processing and isolates failures. It cannot restore producer order; apply per-entity sequence/version checks if required.
|
|
92
|
+
|
|
93
|
+
[Interactive API reference](https://hooka-relay.vercel.app/docs#api-reference) · [Security and migration](https://github.com/wauul/hooka-relay/blob/master/SECURITY.md)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hooka-relay-python"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Typed Hooka Relay event client and Standard Webhooks verification"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = ["standardwebhooks==1.1.0"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Documentation = "https://hooka-relay.vercel.app/docs"
|
|
16
|
+
Source = "https://github.com/wauul/hooka-relay/tree/master/packages/python"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.package-data]
|
|
19
|
+
hooka_relay = ["py.typed"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Server-side event client. API keys must never be shipped to a browser."""
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
from urllib.error import HTTPError
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
7
|
+
from standardwebhooks.webhooks import Webhook
|
|
8
|
+
from .generated import EventInput, EventResponse, ErrorResponse, JsonValue
|
|
9
|
+
|
|
10
|
+
__all__ = ["HookaRelay", "HookaError", "verify_webhook", "EventInput", "EventResponse", "ErrorResponse", "JsonValue"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HookaError(Exception):
|
|
14
|
+
def __init__(self, status: int, body: object, retry_after: str | None):
|
|
15
|
+
super().__init__(f"Hooka Relay returned HTTP {status}")
|
|
16
|
+
self.status = status
|
|
17
|
+
self.body = body
|
|
18
|
+
self.retry_after = retry_after
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
22
|
+
# Never forward a publishing credential to a redirected origin.
|
|
23
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HookaRelay:
|
|
28
|
+
def __init__(self, api_key: str, *, base_url: str = "https://hooka-relay.vercel.app", timeout: float = 30):
|
|
29
|
+
if not api_key or "\r" in api_key or "\n" in api_key:
|
|
30
|
+
raise ValueError("An API key is required")
|
|
31
|
+
url = urlparse(base_url)
|
|
32
|
+
if url.scheme not in ("https", "http") or not url.hostname or url.username or url.password:
|
|
33
|
+
raise ValueError("Invalid base URL")
|
|
34
|
+
if url.scheme == "http" and url.hostname not in ("localhost", "127.0.0.1", "::1"):
|
|
35
|
+
raise ValueError("Use HTTPS outside localhost")
|
|
36
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
37
|
+
raise ValueError("Invalid timeout")
|
|
38
|
+
self._url = f"{url.scheme}://{url.netloc}/api/v1/events"
|
|
39
|
+
self._api_key = api_key
|
|
40
|
+
self._timeout = timeout
|
|
41
|
+
self._opener = build_opener(_NoRedirect())
|
|
42
|
+
|
|
43
|
+
def send_event(self, event: EventInput) -> EventResponse:
|
|
44
|
+
"""Send once. For retries, reuse an explicit idempotencyKey."""
|
|
45
|
+
request = Request(self._url, data=json.dumps(event, allow_nan=False).encode(), method="POST", headers={
|
|
46
|
+
"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json",
|
|
47
|
+
})
|
|
48
|
+
try:
|
|
49
|
+
response = self._opener.open(request, timeout=self._timeout)
|
|
50
|
+
except HTTPError as error:
|
|
51
|
+
response = error
|
|
52
|
+
with response:
|
|
53
|
+
raw = response.read().decode("utf-8", errors="replace")
|
|
54
|
+
try:
|
|
55
|
+
body = json.loads(raw)
|
|
56
|
+
except ValueError:
|
|
57
|
+
body = raw
|
|
58
|
+
if response.status != 202:
|
|
59
|
+
raise HookaError(response.status, body, response.headers.get("Retry-After"))
|
|
60
|
+
if not isinstance(body, dict) or not isinstance(body.get("id"), str):
|
|
61
|
+
raise ValueError("Invalid Hooka Relay event response")
|
|
62
|
+
return body
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def verify_webhook(raw_body: str | bytes, headers: dict[str, str], secret: str) -> object:
|
|
66
|
+
"""Verify exact raw bytes, signed event ID and the reference library's clock window."""
|
|
67
|
+
return Webhook(secret).verify(raw_body, headers)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Generated from docs/openapi.json; run npm run sdk:generate.
|
|
2
|
+
from typing import NotRequired, TypedDict, Union
|
|
3
|
+
|
|
4
|
+
JsonValue = Union[None, bool, int, float, str, list["JsonValue"], dict[str, "JsonValue"]]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EventInput(TypedDict):
|
|
8
|
+
type: str
|
|
9
|
+
payload: JsonValue
|
|
10
|
+
idempotencyKey: NotRequired[str]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventResponse(TypedDict):
|
|
14
|
+
id: str
|
|
15
|
+
applicationId: str
|
|
16
|
+
type: str
|
|
17
|
+
payload: JsonValue
|
|
18
|
+
idempotencyKey: str
|
|
19
|
+
operational: bool
|
|
20
|
+
createdAt: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SchemaFailure(TypedDict):
|
|
24
|
+
path: str
|
|
25
|
+
message: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ErrorResponse(TypedDict):
|
|
29
|
+
error: str
|
|
30
|
+
retryAfter: NotRequired[int]
|
|
31
|
+
failures: NotRequired[list[SchemaFailure]]
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hooka-relay-python
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Typed Hooka Relay event client and Standard Webhooks verification
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Documentation, https://hooka-relay.vercel.app/docs
|
|
7
|
+
Project-URL: Source, https://github.com/wauul/hooka-relay/tree/master/packages/python
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: standardwebhooks==1.1.0
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# hooka-relay-python
|
|
15
|
+
|
|
16
|
+
Python 3.11+ client for [Hooka Relay](https://hooka-relay.vercel.app/docs#api-reference).
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pip install hooka-relay-python
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import os
|
|
24
|
+
from hooka_relay import HookaRelay
|
|
25
|
+
relay = HookaRelay(os.environ["HOOKA_API_KEY"])
|
|
26
|
+
event = relay.send_event({
|
|
27
|
+
"type": "order.created", "payload": {"orderId": "123"},
|
|
28
|
+
"idempotencyKey": "order-123-created",
|
|
29
|
+
})
|
|
30
|
+
print(event["id"])
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Use an ingest-only or existing unscoped application key. Optional `base_url` and `timeout` (seconds, default 30) configure the client. Remote URLs require HTTPS; redirects are rejected to avoid credential forwarding. There are no implicit retries. Retry ambiguous network failures with the **same explicit idempotencyKey**; omitted keys are generated by the server, so resending without one can create another event.
|
|
34
|
+
|
|
35
|
+
`HookaError` exposes `status`, parsed `body`, and `retry_after`. Rate limits return 429, oversized requests 413, and schema failures 400 with `failures: [{path, message}]`. Payload limits: 256 KiB / JSON depth 32. TypedDict models are generated from the repository's OpenAPI contract; runtime server validation remains authoritative.
|
|
36
|
+
|
|
37
|
+
## Verify and queue, then drain
|
|
38
|
+
|
|
39
|
+
`verify_webhook(raw_body, headers, secret)` uses the Standard Webhooks reference library. It raises on invalid signatures, a changed ID/body, or timestamps outside five minutes. Pass raw bytes and the displayed `whsec_` secret. During rotation either old or new key verifies the dual signatures. Existing LEGACY endpoints must explicitly migrate after their receiver supports Standard Webhooks.
|
|
40
|
+
|
|
41
|
+
Minimal queue-and-drain receiver using the standard library (put a production HTTP server/reverse proxy in front of a real deployment). A bounded queue rejects overload with 503; accepted work is processed outside the request.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import os
|
|
45
|
+
import logging
|
|
46
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
47
|
+
from queue import Queue, Full
|
|
48
|
+
from threading import Thread
|
|
49
|
+
from hooka_relay import verify_webhook
|
|
50
|
+
|
|
51
|
+
queue = Queue(maxsize=1000)
|
|
52
|
+
|
|
53
|
+
def process_event(item):
|
|
54
|
+
print(item["id"], item["payload"])
|
|
55
|
+
|
|
56
|
+
def drain():
|
|
57
|
+
while True:
|
|
58
|
+
item = queue.get()
|
|
59
|
+
try:
|
|
60
|
+
process_event(item)
|
|
61
|
+
except Exception:
|
|
62
|
+
logging.exception("Persist failed item to your dead-letter store: %s", item["id"])
|
|
63
|
+
finally:
|
|
64
|
+
queue.task_done()
|
|
65
|
+
|
|
66
|
+
class Receiver(BaseHTTPRequestHandler):
|
|
67
|
+
def do_POST(self):
|
|
68
|
+
if self.path != "/webhook":
|
|
69
|
+
self.send_error(404)
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
length = int(self.headers.get("Content-Length", "-1"))
|
|
73
|
+
except ValueError:
|
|
74
|
+
self.send_error(400)
|
|
75
|
+
return
|
|
76
|
+
if length < 0 or self.headers.get("Transfer-Encoding"):
|
|
77
|
+
self.send_error(411)
|
|
78
|
+
return
|
|
79
|
+
if length > 262144:
|
|
80
|
+
self.send_error(413)
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
headers = {k: self.headers.get(k, "") for k in ("webhook-id", "webhook-timestamp", "webhook-signature")}
|
|
84
|
+
payload = verify_webhook(self.rfile.read(length), headers, os.environ["HOOKA_SIGNING_SECRET"])
|
|
85
|
+
except Exception:
|
|
86
|
+
self.send_error(400)
|
|
87
|
+
return
|
|
88
|
+
try:
|
|
89
|
+
queue.put_nowait({"id": headers["webhook-id"], "payload": payload})
|
|
90
|
+
except Full:
|
|
91
|
+
self.send_error(503)
|
|
92
|
+
return
|
|
93
|
+
self.send_response(202)
|
|
94
|
+
self.end_headers()
|
|
95
|
+
|
|
96
|
+
Thread(target=drain, daemon=True).start()
|
|
97
|
+
ThreadingHTTPServer(("127.0.0.1", 8080), Receiver).serve_forever()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The queue is **volatile**: a crash loses already acknowledged work. For production, persist to a durable inbox/queue before acknowledging. Atomically deduplicate the authenticated `webhook-id` with business changes. Persist processing failures for retry or dead-letter handling; a log entry is not durable recovery.
|
|
101
|
+
|
|
102
|
+
## Event ordering
|
|
103
|
+
|
|
104
|
+
Ordering is **not guaranteed across retries or replay generations**. The queue-and-drain example above separates acknowledgement from slow processing and isolates failures. It cannot restore producer order; apply per-entity sequence/version checks if required.
|
|
105
|
+
|
|
106
|
+
[Interactive API reference](https://hooka-relay.vercel.app/docs#api-reference) · [Security and migration](https://github.com/wauul/hooka-relay/blob/master/SECURITY.md)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/hooka_relay/__init__.py
|
|
5
|
+
src/hooka_relay/generated.py
|
|
6
|
+
src/hooka_relay/py.typed
|
|
7
|
+
src/hooka_relay_python.egg-info/PKG-INFO
|
|
8
|
+
src/hooka_relay_python.egg-info/SOURCES.txt
|
|
9
|
+
src/hooka_relay_python.egg-info/dependency_links.txt
|
|
10
|
+
src/hooka_relay_python.egg-info/requires.txt
|
|
11
|
+
src/hooka_relay_python.egg-info/top_level.txt
|
|
12
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
standardwebhooks==1.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hooka_relay
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import unittest
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from unittest.mock import Mock
|
|
7
|
+
from urllib.error import HTTPError
|
|
8
|
+
from hooka_relay import HookaRelay, HookaError, verify_webhook
|
|
9
|
+
from standardwebhooks.webhooks import Webhook
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Response(io.BytesIO):
|
|
13
|
+
def __init__(self, body, status=202):
|
|
14
|
+
super().__init__(json.dumps(body).encode())
|
|
15
|
+
self.status = status
|
|
16
|
+
self.headers = {"Retry-After": "12"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClientTests(unittest.TestCase):
|
|
20
|
+
def test_send(self):
|
|
21
|
+
client = HookaRelay("test-key")
|
|
22
|
+
event = {"id": "evt_1", "type": "test", "payload": None}
|
|
23
|
+
client._opener = Mock()
|
|
24
|
+
client._opener.open.return_value = Response(event)
|
|
25
|
+
request = {"type": "test", "payload": None, "idempotencyKey": "stable"}
|
|
26
|
+
self.assertEqual(client.send_event(request), event)
|
|
27
|
+
sent = client._opener.open.call_args.args[0]
|
|
28
|
+
self.assertEqual(sent.full_url, "https://hooka-relay.vercel.app/api/v1/events")
|
|
29
|
+
self.assertEqual(json.loads(sent.data), request)
|
|
30
|
+
self.assertEqual(sent.get_header("Authorization"), "Bearer test-key")
|
|
31
|
+
client._opener.open.assert_called_once()
|
|
32
|
+
|
|
33
|
+
def test_errors_no_retries(self):
|
|
34
|
+
for status in (400, 401, 403, 413, 429, 503):
|
|
35
|
+
client = HookaRelay("secret-key")
|
|
36
|
+
client._opener = Mock()
|
|
37
|
+
client._opener.open.side_effect = HTTPError(client._url, status, "test", {"Retry-After": "12"}, io.BytesIO(b'{"error":"test"}'))
|
|
38
|
+
with self.assertRaises(HookaError) as caught:
|
|
39
|
+
client.send_event({"type": "test", "payload": None})
|
|
40
|
+
self.assertEqual(caught.exception.status, status)
|
|
41
|
+
self.assertEqual(caught.exception.retry_after, "12")
|
|
42
|
+
self.assertNotIn("secret-key", str(caught.exception))
|
|
43
|
+
client._opener.open.assert_called_once()
|
|
44
|
+
|
|
45
|
+
def test_invalid_urls_and_redirect(self):
|
|
46
|
+
for url in ("http://example.com", "https://user:pass@example.com", "file:///test"):
|
|
47
|
+
with self.assertRaises(ValueError):
|
|
48
|
+
HookaRelay("key", base_url=url)
|
|
49
|
+
client = HookaRelay("key")
|
|
50
|
+
redirect = next(h for h in client._opener.handlers if hasattr(h, "redirect_request"))
|
|
51
|
+
self.assertIsNone(redirect.redirect_request(None, None, 302, "", {}, "https://other.example"))
|
|
52
|
+
|
|
53
|
+
def test_reference_verification(self):
|
|
54
|
+
keys = ["whsec_" + base64.b64encode(bytes([n]) * 32).decode() for n in (1, 2)]
|
|
55
|
+
now = datetime.now(timezone.utc)
|
|
56
|
+
raw, event_id = '{"hello":"world"}', "evt_stable"
|
|
57
|
+
headers = {"webhook-id": event_id, "webhook-timestamp": str(int(now.timestamp())), "webhook-signature": " ".join(Webhook(k).sign(event_id, now, raw) for k in keys)}
|
|
58
|
+
for key in keys:
|
|
59
|
+
self.assertEqual(verify_webhook(raw, headers, key), {"hello": "world"})
|
|
60
|
+
for altered, body in (({**headers, "webhook-id": "forged"}, raw), (headers, raw + " ")):
|
|
61
|
+
with self.assertRaises(Exception):
|
|
62
|
+
verify_webhook(body, altered, keys[0])
|
|
63
|
+
old = now - timedelta(minutes=10)
|
|
64
|
+
with self.assertRaises(Exception):
|
|
65
|
+
verify_webhook(raw, {**headers, "webhook-timestamp": str(int(old.timestamp())), "webhook-signature": Webhook(keys[0]).sign(event_id, old, raw)}, keys[0])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
unittest.main()
|