Webhooks and outbound calls¶
Standard Webhooks in and out, and httpx transports with deadlines, breakers and SSRF checks.
In fastapi_alcazaba.webhooks and fastapi_alcazaba.outbound.
In the guide: Signed webhooks, Outbound calls. In an app: the example.
fastapi_alcazaba.webhooks.FastAPIWebhooks
¶
FastAPIWebhooks(secret: WebhookSecrets, *, tolerance: int = 300, cache: Cache | None = None, max_body_size: int = 1048576, namespace: str = 'webhook', fail_open: bool = False)
FastAPI dependency that accepts only webhooks signed with secret, and each delivery once.
webhooks = FastAPIWebhooks(settings.webhook_secret)
@app.post("/webhooks")
async def receive(message: Annotated[WebhookMessage, Depends(webhooks)]) -> None: ...
It reads the raw body (up to max_body_size bytes, a 413 beyond) and leaves it in the request,
for message.json() or await request.json(). Validate it there, not with a pydantic body
parameter: FastAPI reads and validates those before any dependency, without the size limit and
before the signature is checked. It can't read a body the endpoint takes as a form, which
consumes it first: webhooks are JSON.
Missing or malformed headers get a 400; a wrong signature, a timestamp more than tolerance
seconds away from the server's clock (either way) and a delivery accepted before all get the
same 401, which doesn't say which check failed.
Replays: each delivery accepted (its id and timestamp, hashed) is counted in the cache's
backend until its timestamp can't be accepted anymore, so every process refuses it again. A
retry of the sender has a new timestamp and signature and goes through: Standard Webhooks
keeps the id of a message for all its attempts, and a retry after a failed (5xx) attempt must
get through. So make the endpoint idempotent on message.id (a unique constraint), as the
specification advises, if a message must not be processed twice. Whoever can write to the
backend can delete the marks (and allow a replay within tolerance): keep it out of reach.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
secret
|
WebhookSecrets
|
|
required |
tolerance
|
int
|
seconds a timestamp may be away from the server's clock, either way. |
300
|
cache
|
Cache | None
|
the Cache whose backend counts the deliveries (it must support counters); the
app's ( |
None
|
max_body_size
|
int
|
larger bodies get a 413 without being read to the end. |
1048576
|
namespace
|
str
|
part of the keys, after the cache's prefix (see |
'webhook'
|
fail_open
|
bool
|
when the backend fails, accept the (correctly signed) webhook without the replay check instead of answering 503. Off by default: senders retry a 503 later, while a replay would run its effects again. |
False
|
verify
¶
verify(headers: Mapping[str, str], body: bytes) -> WebhookMessage
The message, if headers carry a valid signature of body and a fresh timestamp;
raises an HTTPException (400 or 401) otherwise. It doesn't check for replays: for tests,
scripts and queues that deduplicate on their own.
fastapi_alcazaba.webhooks.WebhookSigner
¶
WebhookSigner(secret: WebhookSecrets)
Signs outgoing webhooks the Standard Webhooks way.
signer = WebhookSigner(settings.webhook_secret)
body = json.dumps(event).encode()
await client.post(url, content=body, headers=signer.sign(body))
Sign the exact bytes you send: json=event would serialize them again, maybe differently.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
secret
|
WebhookSecrets
|
|
required |
generate_secret
staticmethod
¶
generate_secret() -> str
A new random secret (256 bits), as whsec_<base64>.
sign
¶
sign(body: bytes | str, *, message_id: str | None = None, timestamp: float | datetime | None = None) -> dict[str, str]
The headers of a webhook carrying body: webhook-id, webhook-timestamp and
webhook-signature. Give the same message_id to every attempt of a message (a random
one by default) and leave timestamp (now) alone: each attempt is signed afresh.
fastapi_alcazaba.webhooks.WebhookMessage
dataclass
¶
WebhookMessage(id: str, timestamp: datetime, body: bytes)
A webhook whose signature was checked: what FastAPIWebhooks gives the endpoint.
fastapi_alcazaba.outbound.ResilientTransport
¶
ResilientTransport(transport: AsyncBaseTransport | None = None, *, timeout: float | None = 10.0, failure_threshold: int | None = 5, reset_timeout: float = 30.0, failure_statuses: Collection[int] = (500, 502, 503, 504), max_response_size: int | None = None, metrics: Metrics | None = None)
Bases: AsyncBaseTransport
An httpx transport that gives every call a deadline and every origin a circuit breaker.
client = httpx.AsyncClient(transport=ResilientTransport(timeout=5), base_url=...)
Responses are read whole within the deadline before they're returned, so don't stream large
downloads through it; max_response_size bounds what it reads.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
transport
|
AsyncBaseTransport | None
|
the transport that sends the requests; |
None
|
timeout
|
float | None
|
seconds for the whole call, from sending the request to the last byte of the
response; |
10.0
|
failure_threshold
|
int | None
|
consecutive failures of an origin (scheme, host and port) after
which it isn't called for |
5
|
reset_timeout
|
float
|
seconds an open breaker refuses calls. |
30.0
|
failure_statuses
|
Collection[int]
|
response statuses that count as failures, besides errors and timeouts. The response is still returned. |
(500, 502, 503, 504)
|
max_response_size
|
int | None
|
bytes of response body read at most, as they come (before
Content-Encoding is decoded); |
None
|
metrics
|
Metrics | None
|
where to count breakers opening and refusing calls
( |
None
|
state
¶
state(url: str | URL) -> str
The breaker of url's origin: "closed", "open" or "half-open" (a trial may go
through).
fastapi_alcazaba.outbound.CircuitOpenError
¶
Bases: TransportError
The origin's circuit breaker is open: the request wasn't sent.
fastapi_alcazaba.outbound.ResponseTooLargeError
¶
Bases: RequestError
The response body was larger than max_response_size: the rest wasn't read. A limit of the
client, like httpx.TooManyRedirects, not a transport error: retrying won't help.
fastapi_alcazaba.outbound.SSRFGuardTransport
¶
SSRFGuardTransport(transport: InnerTransport | None = None, *, allow: Iterable[AllowedDestination] = (), ports: Collection[int] | None = None, max_hosts: int = 100, metrics: Metrics | None = None)
Bases: AsyncBaseTransport
An httpx transport that only calls the public internet: no private, loopback, link-local (cloud metadata), multicast or reserved addresses, whatever the URL says.
client = httpx.AsyncClient(transport=ResilientTransport(SSRFGuardTransport()))
It resolves the host itself and refuses the request (BlockedDestinationError) if any of its
addresses isn't public, IPv6 forms of IPv4 addresses (::ffff:127.0.0.1, NAT64, 6to4) and
numeric spellings (2130706433, 0x7f.1) included. Then it connects to the address it
checked: the URL is rewritten to it, the Host header keeps the name, and TLS gets the name
for SNI and the certificate check. A DNS server that answers a public address to the check and
a private one to the connection (DNS rebinding) gets nowhere. Each hop of a redirect comes
through the transport again and is checked like the first.
Wrap it in ResilientTransport rather than the other way round: the deadline then covers the
DNS lookup, and each breaker is per host name. Give the transport to the client, not as one of
its mounts, and give the client no proxy: those requests wouldn't go through it. A proxy
in the inner transport is refused: it would connect on its own (an egress proxy that filters
destinations, such as Smokescreen, is the alternative to this transport, not its companion).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
transport
|
InnerTransport | None
|
what sends the requests. By default each host name gets an
|
None
|
allow
|
Iterable[AllowedDestination]
|
host names (exact, or |
()
|
ports
|
Collection[int] | None
|
the only ports allowed ( |
None
|
max_hosts
|
int
|
host names with a transport of their own kept open at most (the least recently used ones without an open response are closed). |
100
|
metrics
|
Metrics | None
|
where to count refused requests ( |
None
|
check
async
¶
check(url: URL | str, *, timeout: float | None = 5.0) -> None
Check url as a request to it would be, without sending anything: its scheme, its port
and every address its host resolves to (within timeout seconds). Raises
BlockedDestinationError when a request would be refused, and httpx.ConnectError when the
host doesn't resolve. For a URL that users give (a webhook's endpoint), when they give it;
every request is checked again all the same, as DNS answers change.
fastapi_alcazaba.outbound.BlockedDestinationError
¶
Bases: RequestError
SSRFGuardTransport didn't send the request: its scheme, port or address isn't allowed.
A limit of the client, like ResponseTooLargeError: it isn't counted against the origin.