Skip to content

Commit 1e01008

Browse files
committed
Release v1.1.0 — async hooks callbacks
1 parent 9e738f3 commit 1e01008

9 files changed

Lines changed: 534 additions & 1 deletion

File tree

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,74 @@ delivery = client.signatures.parse_delivery(
330330
)
331331
```
332332

333+
## Async Hooks
334+
335+
When [async hooks](https://posthook.io/docs/essentials/async-hooks) are enabled, `parse_delivery()` populates `ack_url` and `nack_url` on the delivery object. Return 202 from your handler and call back when processing completes.
336+
337+
### FastAPI
338+
339+
```python
340+
from fastapi import FastAPI, Request, BackgroundTasks, HTTPException
341+
from fastapi.responses import Response
342+
import posthook
343+
344+
app = FastAPI()
345+
client = posthook.Posthook("pk_...", signing_key="ph_sk_...")
346+
347+
async def process_and_ack(delivery):
348+
try:
349+
await process_video(delivery.data["video_id"])
350+
result = await posthook.async_ack(delivery.ack_url)
351+
print(f"Applied: {result.applied}")
352+
except Exception as e:
353+
await posthook.async_nack(delivery.nack_url, {"error": str(e)})
354+
355+
@app.post("/webhooks/process-video")
356+
async def handle_webhook(request: Request, background_tasks: BackgroundTasks):
357+
body = await request.body()
358+
try:
359+
delivery = client.signatures.parse_delivery(body=body, headers=dict(request.headers))
360+
except posthook.SignatureVerificationError:
361+
raise HTTPException(status_code=401)
362+
363+
background_tasks.add_task(process_and_ack, delivery)
364+
return Response(status_code=202)
365+
```
366+
367+
### Callback functions
368+
369+
The SDK provides standalone callback functions -- pass the URL from the delivery object:
370+
371+
```python
372+
# Sync (Flask, Django, background workers)
373+
result = posthook.ack(delivery.ack_url)
374+
result = posthook.nack(delivery.nack_url, {"error": "processing failed"})
375+
376+
# Async (FastAPI, etc.)
377+
result = await posthook.async_ack(delivery.ack_url)
378+
result = await posthook.async_nack(delivery.nack_url, {"error": "processing failed"})
379+
```
380+
381+
Both return a `CallbackResult`:
382+
383+
```python
384+
result = posthook.ack(delivery.ack_url)
385+
print(result.applied) # True if state changed, False if already resolved
386+
print(result.status) # "completed", "not_found", "conflict", etc.
387+
```
388+
389+
`ack()` and `nack()` return normally for `200`, `404`, and `409` responses. They raise `CallbackError` for `401` (invalid token) and `410` (expired).
390+
391+
If processing happens in a separate worker, use the raw callback URLs instead:
392+
393+
```python
394+
queue.enqueue("transcode", {
395+
"video_id": delivery.data["video_id"],
396+
"ack_url": delivery.ack_url,
397+
"nack_url": delivery.nack_url,
398+
})
399+
```
400+
333401
## Error Handling
334402

335403
All API errors extend `PosthookError` and can be caught with `isinstance` or `except`:

src/posthook/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Posthook Python SDK — schedule, manage, and verify webhooks."""
22

3+
from ._callbacks import ack, async_ack, async_nack, nack
34
from ._client import AsyncPosthook, Posthook
45
from ._errors import (
56
AuthenticationError,
67
BadRequestError,
8+
CallbackError,
79
ForbiddenError,
810
InternalServerError,
911
NotFoundError,
@@ -25,6 +27,7 @@
2527
STRATEGY_EXPONENTIAL,
2628
STRATEGY_FIXED,
2729
BulkActionResult,
30+
CallbackResult,
2831
Delivery,
2932
Hook,
3033
HookRetryOverride,
@@ -42,7 +45,13 @@
4245
"HookRetryOverride",
4346
"QuotaInfo",
4447
"BulkActionResult",
48+
"CallbackResult",
4549
"Delivery",
50+
# Callbacks
51+
"ack",
52+
"nack",
53+
"async_ack",
54+
"async_nack",
4655
# Resources
4756
"SignaturesService",
4857
"create_signatures",
@@ -61,6 +70,7 @@
6170
"PosthookError",
6271
"BadRequestError",
6372
"AuthenticationError",
73+
"CallbackError",
6474
"ForbiddenError",
6575
"NotFoundError",
6676
"PayloadTooLargeError",

src/posthook/_callbacks.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Sync and async helpers for ack/nack callbacks on async hook deliveries."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from typing import Any
7+
8+
import httpx
9+
10+
from ._errors import CallbackError
11+
from ._models import CallbackResult
12+
13+
14+
def _parse_callback_response(
15+
response: httpx.Response,
16+
action: str,
17+
expected_status: str,
18+
) -> CallbackResult:
19+
"""Parse an ack/nack HTTP response into a CallbackResult.
20+
21+
2xx → parse ``{data: {status}}`` from JSON, ``applied = (status == expected)``.
22+
404 → ``CallbackResult(applied=False, status="not_found")``.
23+
409 → ``CallbackResult(applied=False, status="conflict")``.
24+
Other → raise ``CallbackError``.
25+
"""
26+
if response.is_success:
27+
try:
28+
data = response.json()
29+
status = data.get("data", {}).get("status", "unknown")
30+
except Exception:
31+
status = "unknown"
32+
return CallbackResult(applied=(status == expected_status), status=status)
33+
34+
if response.status_code == 404:
35+
return CallbackResult(applied=False, status="not_found")
36+
if response.status_code == 409:
37+
return CallbackResult(applied=False, status="conflict")
38+
39+
text = response.text
40+
suffix = f": {text}" if text else ""
41+
raise CallbackError(
42+
f"{action} failed: {response.status_code}{suffix}",
43+
status_code=response.status_code,
44+
)
45+
46+
47+
def _prepare_request(body: Any) -> tuple[bytes | None, dict[str, str]]:
48+
"""Prepare content and headers for a callback request."""
49+
if body is not None:
50+
return json.dumps(body).encode(), {"Content-Type": "application/json"}
51+
return None, {}
52+
53+
54+
def ack(url: str, body: Any = None) -> CallbackResult:
55+
"""Acknowledge async processing completion (synchronous).
56+
57+
Args:
58+
url: The ack callback URL from ``delivery.ack_url``.
59+
body: Optional JSON-serializable body to send with the callback.
60+
Posthook currently ignores ack bodies.
61+
62+
Returns:
63+
A ``CallbackResult`` indicating whether the ack was applied.
64+
65+
Raises:
66+
CallbackError: For unexpected failures (401, 410, 5xx).
67+
"""
68+
content, headers = _prepare_request(body)
69+
response = httpx.post(url, content=content, headers=headers)
70+
return _parse_callback_response(response, "ack", "completed")
71+
72+
73+
def nack(url: str, body: Any = None) -> CallbackResult:
74+
"""Reject async processing — triggers retry or failure (synchronous).
75+
76+
Args:
77+
url: The nack callback URL from ``delivery.nack_url``.
78+
body: Optional JSON-serializable body explaining the failure.
79+
80+
Returns:
81+
A ``CallbackResult`` indicating whether the nack was applied.
82+
83+
Raises:
84+
CallbackError: For unexpected failures (401, 410, 5xx).
85+
"""
86+
content, headers = _prepare_request(body)
87+
response = httpx.post(url, content=content, headers=headers)
88+
return _parse_callback_response(response, "nack", "nacked")
89+
90+
91+
async def async_ack(url: str, body: Any = None) -> CallbackResult:
92+
"""Acknowledge async processing completion (asynchronous).
93+
94+
Args:
95+
url: The ack callback URL from ``delivery.ack_url``.
96+
body: Optional JSON-serializable body to send with the callback.
97+
Posthook currently ignores ack bodies.
98+
99+
Returns:
100+
A ``CallbackResult`` indicating whether the ack was applied.
101+
102+
Raises:
103+
CallbackError: For unexpected failures (401, 410, 5xx).
104+
"""
105+
content, headers = _prepare_request(body)
106+
async with httpx.AsyncClient() as client:
107+
response = await client.post(url, content=content, headers=headers)
108+
return _parse_callback_response(response, "ack", "completed")
109+
110+
111+
async def async_nack(url: str, body: Any = None) -> CallbackResult:
112+
"""Reject async processing — triggers retry or failure (asynchronous).
113+
114+
Args:
115+
url: The nack callback URL from ``delivery.nack_url``.
116+
body: Optional JSON-serializable body explaining the failure.
117+
118+
Returns:
119+
A ``CallbackResult`` indicating whether the nack was applied.
120+
121+
Raises:
122+
CallbackError: For unexpected failures (401, 410, 5xx).
123+
"""
124+
content, headers = _prepare_request(body)
125+
async with httpx.AsyncClient() as client:
126+
response = await client.post(url, content=content, headers=headers)
127+
return _parse_callback_response(response, "nack", "nacked")

src/posthook/_errors.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ def __init__(self, message: str) -> None:
9797
super().__init__(message, code="signature_verification_error")
9898

9999

100+
class CallbackError(PosthookError):
101+
"""Raised when an ack/nack callback fails unexpectedly.
102+
103+
This is thrown for non-recoverable failures such as invalid tokens (401),
104+
expired tokens (410), or server errors (5xx). Expected no-ops like 404
105+
(hook deleted) and 409 (stale token) are returned as ``CallbackResult``
106+
with ``applied=False`` instead.
107+
"""
108+
109+
def __init__(self, message: str, status_code: int | None = None) -> None:
110+
super().__init__(message, status_code=status_code, code="callback_error")
111+
112+
100113
def _create_error(
101114
status_code: int,
102115
message: str,

src/posthook/_models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,21 @@ def from_dict(cls, data: dict[str, Any]) -> BulkActionResult:
148148

149149

150150
@dataclass(frozen=True)
151+
class CallbackResult:
152+
"""Result of an ack or nack callback.
153+
154+
Both ``ack()`` and ``nack()`` return this for all expected outcomes,
155+
including race conditions where the hook already resolved. Check
156+
``applied`` to see if your callback changed the hook's state.
157+
"""
158+
159+
applied: bool
160+
"""Whether the callback changed the hook's state."""
161+
status: str
162+
"""The hook's current status (e.g. ``"completed"``, ``"nacked"``, ``"not_found"``)."""
163+
164+
165+
@dataclass
151166
class Delivery:
152167
"""A parsed and verified webhook delivery."""
153168

@@ -160,3 +175,7 @@ class Delivery:
160175
posted_at: datetime
161176
created_at: datetime
162177
updated_at: datetime
178+
ack_url: str | None = None
179+
"""Callback URL for acknowledging async processing. Present when both ack and nack headers exist."""
180+
nack_url: str | None = None
181+
"""Callback URL for negative acknowledgement. Present when both ack and nack headers exist."""

src/posthook/_resources/_signatures.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ def parse_delivery(
128128
f"Failed to parse delivery payload: {exc}"
129129
)
130130

131+
# Extract async callback URLs (set both or neither).
132+
ack_url = _get_header(headers, "Posthook-Ack-URL")
133+
nack_url = _get_header(headers, "Posthook-Nack-URL")
134+
has_callbacks = bool(ack_url and nack_url)
135+
131136
return Delivery(
132137
hook_id=hook_id,
133138
timestamp=timestamp,
@@ -138,6 +143,8 @@ def parse_delivery(
138143
posted_at=_parse_dt(payload.get("postedAt", "")),
139144
created_at=_parse_dt(payload.get("createdAt", "")),
140145
updated_at=_parse_dt(payload.get("updatedAt", "")),
146+
ack_url=ack_url if has_callbacks else None,
147+
nack_url=nack_url if has_callbacks else None,
141148
)
142149

143150

src/posthook/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
VERSION = "1.0.0"
1+
VERSION = "1.1.0"

0 commit comments

Comments
 (0)