Chapter 4: Networking Fundamentals for Chat Integrations
Learning Objectives
Explain the HTTP request/response cycle and the status codes most relevant to integrations, and decide which errors are safe to retry.
Describe how TLS secures a connection and how HMAC request verification lets a public webhook endpoint prove an inbound request is authentic and untampered.
Make concurrent async HTTP requests with httpx or aiohttp, and run a minimal async HTTP server with FastAPI and uvicorn.
Reason about connection reuse, keep-alive, pooling, and timeouts for outbound API calls, and apply exponential backoff for resilient retries.
Pre-Reading Check — HTTP for Integrators
An HTTP request is composed of which four parts?
Status code, headers, body, and cookies
Method, path, headers, and an optional body
URL, port, protocol, and payload
Verb, certificate, session key, and body
Your gateway sends a request and receives a 503 Service Unavailable. What is the appropriate response?
Fix the request body, because a 5xx means you sent something invalid
Retry with exponential backoff, because 5xx errors are usually transient
Stop permanently, because 5xx errors are never recoverable
Follow the Location header to a new endpoint
Why is retrying a POST that creates a message dangerous, and what makes it safe?
POST is naturally idempotent, so retries are always safe with no extra work
Retrying a POST can duplicate the message; an idempotency key lets the server recognize the retry
POST retries are safe only over HTTP/2, which deduplicates automatically
Retrying a POST is safe as long as you change the Authorization header each time
Slack's Web API can return an HTTP 200 OK while still signaling a failure. How should an integrator handle this?
Trust the 200 and assume success, since the HTTP layer is authoritative
Check both the HTTP status code and an application-level field such as "ok": false in the body
Retry the request until it returns a 201 instead of a 200
Ignore the body entirely and rely only on response headers
When an API returns 429 Too Many Requests with a Retry-After header, the well-behaved gateway should:
Immediately retry the same request as fast as possible
Treat it as a permanent 4xx failure and give up
Wait the number of seconds indicated by Retry-After before retrying
Switch to a different HTTP method to bypass the limit
HTTP for Integrators
Key Points
An HTTP exchange is a request (method, path, headers, optional body) answered by a response (status code, headers, body).
Status-code class drives behavior: 2xx proceed, 4xx fix your request (do not blindly retry), 5xx retry with backoff. The lone retryable 4xx is 429.
Retries are only safe when the operation is idempotent; use an idempotency key to make POSTs safe from the double-send bug.
On 429, honor the Retry-After header rather than hammering the endpoint.
Chat APIs are REST-style: noun-based URLs, verbs for actions, JSON bodies, and Authorization headers — always check both the status code and any in-body success flag.
HTTP (HyperText Transfer Protocol) is the request/response protocol that every REST-style chat API speaks. A client sends a request; a server returns a response. The details of methods, headers, bodies, and status codes are exactly what separate a fragile integration from a robust one.
The methods you will use most as an integrator map to CRUD operations:
Method
Meaning
Example in a gateway
GET
Read a resource
Fetch a channel's message history
POST
Create / submit
Post a reply message to Slack
PUT
Replace a resource
Overwrite a saved user preference
PATCH
Partially update
Edit one field of a message
DELETE
Remove a resource
Delete a bot message
Headers carry the metadata that makes the request work: Content-Type: application/json tells the server how to parse the body, Authorization: Bearer <token> proves who you are, and User-Agent identifies your client. The body — for chat APIs, almost always JSON — carries the actual data.
Status codes are grouped into five classes by their leading digit, and knowing the class tells you how to react:
Class
Meaning
Integrator response
1xx
Informational
Rarely seen directly
2xx
Success
Proceed; 200 OK, 201 Created, 204 No Content
3xx
Redirection
Follow the Location (clients usually do this for you)
4xx
Client error — you sent something wrong
Fix the request; do not blindly retry
5xx
Server error — they failed
Safe to retry with backoff
The distinction between 4xx and 5xx is the single most important operational rule in this chapter. A 400 or 401 means retrying the identical request will fail identically. A 500 or 503 is usually transient. The one 4xx you do retry is 429 Too Many Requests.
Figure 4.3: The HTTP request/response cycle for a gateway outbound call.
sequenceDiagram
participant Client as "Gateway (client)"
participant Server as "Chat API (server)"
Client->>Server: "Request: POST /chat.postMessage"
Note over Client,Server: "Headers: Authorization, Content-Type + JSON body"
Server->>Server: "Process request"
alt Success
Server-->>Client: "200 OK / 201 Created + JSON body"
else Client error (4xx)
Server-->>Client: "400 / 401 / 403 (fix request, do not retry)"
else Rate limited
Server-->>Client: "429 + Retry-After header (wait, then retry)"
else Server error (5xx)
Server-->>Client: "500 / 503 (retry with backoff)"
end
Visual animation — coming soon
An operation is idempotent if performing it many times has the same effect as performing it once. GET and DELETE are naturally idempotent; POST is the dangerous one, because naively retrying a "post message" request can produce two identical messages — the classic double-send bug. Because networks fail ambiguously (a lost response looks identical to a lost request), the industry answer is an idempotency key: a unique client-generated identifier that lets the server recognize a retry and return the original result instead of acting twice.
Chat and messaging APIs protect themselves with rate limits. When you exceed one, the API returns 429 Too Many Requests, typically with a Retry-After header telling you how many seconds to wait. A well-behaved gateway honors it rather than hammering the endpoint.
REST (Representational State Transfer) is the architectural style most chat platform APIs follow: resources live at noun-based URLs (/channels/{id}/messages), the method expresses the action, JSON is the interchange format, and authentication travels in the Authorization header. Slack notably can return 200 OK at the HTTP layer while signaling a logical failure via an "ok": false field — so you must check both the status code and the payload.
Post-Reading Check — HTTP for Integrators
An HTTP request is composed of which four parts?
Status code, headers, body, and cookies
Method, path, headers, and an optional body
URL, port, protocol, and payload
Verb, certificate, session key, and body
Your gateway sends a request and receives a 503 Service Unavailable. What is the appropriate response?
Fix the request body, because a 5xx means you sent something invalid
Retry with exponential backoff, because 5xx errors are usually transient
Stop permanently, because 5xx errors are never recoverable
Follow the Location header to a new endpoint
Why is retrying a POST that creates a message dangerous, and what makes it safe?
POST is naturally idempotent, so retries are always safe with no extra work
Retrying a POST can duplicate the message; an idempotency key lets the server recognize the retry
POST retries are safe only over HTTP/2, which deduplicates automatically
Retrying a POST is safe as long as you change the Authorization header each time
Slack's Web API can return an HTTP 200 OK while still signaling a failure. How should an integrator handle this?
Trust the 200 and assume success, since the HTTP layer is authoritative
Check both the HTTP status code and an application-level field such as "ok": false in the body
Retry the request until it returns a 201 instead of a 200
Ignore the body entirely and rely only on response headers
When an API returns 429 Too Many Requests with a Retry-After header, the well-behaved gateway should:
Immediately retry the same request as fast as possible
Treat it as a permanent 4xx failure and give up
Wait the number of seconds indicated by Retry-After before retrying
Switch to a different HTTP method to bypass the limit
Pre-Reading Check — TLS, Auth, and Verification
TLS provides encryption, integrity, and server authentication. What does TLS not guarantee about an inbound webhook?
That the channel is encrypted end to end
That the data was not altered in transit
That whoever opened the connection is really the platform (e.g., Slack)
That the server presented a valid certificate
What is the difference between a bearer token and a signing secret in a gateway?
A bearer token authenticates outbound calls; a signing secret authenticates inbound webhooks
A bearer token authenticates inbound webhooks; a signing secret authenticates outbound calls
They are interchangeable names for the same credential
A bearer token encrypts the channel; a signing secret decrypts it
Why must you capture the raw request body before parsing JSON when verifying an HMAC signature?
JSON parsing is too slow to do before verification
Re-serializing parsed JSON can reorder keys or change whitespace, breaking the signature match
The raw body is encrypted and must be decrypted first
Parsing JSON strips the Authorization header needed for HMAC
Why does Slack's verification use hmac.compare_digest instead of ==?
== cannot compare byte strings in Python
compare_digest runs in constant time, preventing a timing attack that could leak the signature byte by byte
compare_digest is faster than == for long strings
== automatically decodes hex, which corrupts the comparison
Rejecting requests whose timestamp is older than five minutes primarily defends against what?
Timing attacks on the signature comparison
Replay attacks, where an attacker re-sends a captured legitimate request later
TLS certificate expiration
Rate-limit violations from the platform
TLS, Auth, and Verification
Key Points
TLS gives an encrypted, integrity-checked, server-authenticated channel via a one-time handshake — but the handshake is expensive, and TLS alone does not prove who sent an inbound webhook.
A bearer token authenticates the calls you make out; a signing secret authenticates the webhooks that come in. Two secrets, two directions.
HMAC combines a hash (SHA-256) with a shared secret, giving both authenticity and integrity in a single stateless check.
Verify inbound requests by capturing the raw body first, rejecting stale timestamps (anti-replay), recomputing HMAC-SHA256, and comparing with constant-timehmac.compare_digest — never ==.
Because a webhook URL is publicly reachable, signature verification is a mandatory trust boundary: verify before parsing, reject failures with 401/403.
TLS (Transport Layer Security, the "S" in HTTPS) turns a plaintext TCP connection into an encrypted, integrity-protected channel. It does three things: encryption (eavesdroppers see only ciphertext), integrity (tampering is detected), and authentication (the client verifies the server's identity via its certificate).
The setup happens in a TLS handshake performed once when the connection opens. The client and server agree on a cipher, the server presents an X.509 certificate signed by a trusted Certificate Authority, the client validates it, and the two derive a shared session key. The handshake involves multiple round trips and public-key cryptography, making it computationally and latency-expensive — a fact central to connection pooling. Every reused connection saves a full handshake.
For a gateway this means webhook endpoints must be served over HTTPS. But TLS guarantees only that the channel is confidential; it does not prove that whoever POSTed to your public URL is really Slack. Anyone on the internet can open a TLS connection. That gap is what HMAC verification closes.
Figure 4.4: The TLS handshake that opens an encrypted channel.
sequenceDiagram
participant Client
participant Server
Client->>Server: "ClientHello (supported ciphers, random)"
Server-->>Client: "ServerHello (chosen cipher, random)"
Server-->>Client: "X.509 certificate (signed by trusted CA)"
Client->>Client: "Validate certificate against CA trust store"
Client->>Server: "Key exchange (public-key crypto)"
Note over Client,Server: "Both sides derive the shared session key"
Client->>Server: "Finished (encrypted)"
Server-->>Client: "Finished (encrypted)"
Note over Client,Server: "Application data now encrypted with session key"
Two distinct secrets govern trust. A bearer token (Slack's xoxb-...) authenticates outbound calls — the requests you make to the platform, placed in the Authorization: Bearer <token> header. A signing secret authenticates inbound calls — the webhooks the platform sends you. It is never transmitted; instead it is used to compute an HMAC signature over each request.
HMAC (Hash-based Message Authentication Code) combines a hash function — SHA-256 for Slack — with the shared secret. Because both sides hold the secret, both can compute the same HMAC over the same bytes; a third party cannot forge a valid one. Slack's procedure:
Capture the raw request body — the exact bytes Slack sent — before any JSON parsing. Re-serializing parsed JSON can reorder keys or change whitespace and silently break verification.
Check the timestamp for freshness using X-Slack-Request-Timestamp. Reject anything older than five minutes to defend against replay attacks.
Build the signature base string: v0:{timestamp}:{body}.
Compute HMAC-SHA256 of that base string using the signing secret as key, hex-encode it, and prefix v0=.
Compare securely against X-Slack-Signature with a constant-time comparison — never ==.
import hashlib
import hmac
import time
def verify_slack(signing_secret: str, timestamp: str, body: bytes, sig: str) -> bool:
# Step 2: reject stale requests to block replay attacks
if abs(time.time() - int(timestamp)) > 60 * 5:
return False
# Step 3: reconstruct the exact base string Slack signed
base = f"v0:{timestamp}:{body.decode()}".encode()
# Step 4: recompute the HMAC-SHA256 signature with the shared secret
computed = "v0=" + hmac.new(
signing_secret.encode(), base, hashlib.sha256
).hexdigest()
# Step 5: constant-time comparison, NOT ==
return hmac.compare_digest(computed, sig)
Why constant-time comparison? A naive computed == sig short-circuits at the first differing byte, so its runtime leaks how many leading bytes matched. An attacker measuring response times could recover the correct signature byte by byte — a timing attack. hmac.compare_digest compares in time independent of where the strings differ.
Figure 4.5: HMAC signature verification flow for an inbound webhook.
flowchart TD
A["Inbound webhook POST arrives"] --> B["Capture raw request body (before JSON parse)"]
B --> C{"Timestamp within 5 minutes?"}
C -->|No| R["Reject: 401 / 403 (possible replay)"]
C -->|Yes| D["Build base string: v0:timestamp:body"]
D --> E["Compute HMAC-SHA256 with signing secret"]
E --> F{"compare_digest(computed, X-Slack-Signature)?"}
F -->|No match| R
F -->|Match| G["Parse JSON and enqueue event"]
G --> H["Return 200 (fast-ack)"]
Figure 4.1: Two secrets, two directions.
flowchart LR
G["Gateway"] -->|"Bearer token in Authorization header (authenticates outbound calls)"| S["Slack API"]
P["Slack (platform)"] -->|"HMAC signature over raw body, verified with signing secret (authenticates inbound webhooks)"| G
Visual animation — coming soon
Your webhook URL is public by necessity, which means it is also public to attackers. Without signature verification, anyone who discovers the URL can POST forged events. TLS does not help; the attacker simply opens their own valid TLS connection. Signature verification is therefore a mandatory trust boundary: read the raw body first, verify before parsing, return 401/403 on failure, and rotate the signing secret immediately if it leaks.
Post-Reading Check — TLS, Auth, and Verification
TLS provides encryption, integrity, and server authentication. What does TLS not guarantee about an inbound webhook?
That the channel is encrypted end to end
That the data was not altered in transit
That whoever opened the connection is really the platform (e.g., Slack)
That the server presented a valid certificate
What is the difference between a bearer token and a signing secret in a gateway?
A bearer token authenticates outbound calls; a signing secret authenticates inbound webhooks
A bearer token authenticates inbound webhooks; a signing secret authenticates outbound calls
They are interchangeable names for the same credential
A bearer token encrypts the channel; a signing secret decrypts it
Why must you capture the raw request body before parsing JSON when verifying an HMAC signature?
JSON parsing is too slow to do before verification
Re-serializing parsed JSON can reorder keys or change whitespace, breaking the signature match
The raw body is encrypted and must be decrypted first
Parsing JSON strips the Authorization header needed for HMAC
Why does Slack's verification use hmac.compare_digest instead of ==?
== cannot compare byte strings in Python
compare_digest runs in constant time, preventing a timing attack that could leak the signature byte by byte
compare_digest is faster than == for long strings
== automatically decodes hex, which corrupts the comparison
Rejecting requests whose timestamp is older than five minutes primarily defends against what?
Timing attacks on the signature comparison
Replay attacks, where an attacker re-sends a captured legitimate request later
TLS certificate expiration
Rate-limit violations from the platform
Pre-Reading Check — Async HTTP Clients
Why is asyncio a natural fit for a gateway's outbound HTTP work?
The work is CPU-bound, so async spreads it across cores
The work is I/O-bound, so one event loop can keep hundreds of in-flight requests alive while waiting
Async removes the need for timeouts entirely
Async guarantees requests never fail
What is the single most important rule for preserving connection pooling and keep-alive?
Create a new AsyncClient or ClientSession for every request
Instantiate one long-lived client and reuse it everywhere
Disable keep-alive so connections close immediately
Open a fresh TLS handshake before each call
What is a key difference in timeout behavior between httpx and aiohttp?
httpx has no default timeout, while aiohttp defaults to 5s
Both apply a 30-second default and cannot be changed
httpx defaults to 5s per phase; aiohttp has none and counts pool-queue wait against the clock
Neither library supports configurable timeouts
Which set of errors is safe to retry with exponential backoff?
400 and 401, because the request is malformed
429, 500, 502, 503, 504, and connection/timeout failures
Only 200 responses
Every 4xx error should be retried aggressively
Why add jitter to an exponential-backoff retry schedule?
It makes the retries happen sooner
It encrypts the retry request
A small random offset prevents many clients from aligning into synchronized retry waves
It converts a POST into an idempotent request
Async HTTP Clients
Key Points
Gateway outbound work is I/O-bound, so one event loop keeps hundreds of requests alive; fan out with coroutines awaited by asyncio.gather.
httpx (sync + async, Requests-like) and aiohttp (async-only) both beat thread pools above ~50–100 concurrent requests.
Reuse one long-lived client to preserve connection pooling and keep-alive; a new client per request defeats both and leaks connections.
Always set explicit timeouts.httpx defaults to 5s per phase (connect/read/write/pool); aiohttp has none and counts pool-queue wait against the clock.
Retry only retryable errors (429/5xx/connection failures) with exponential backoff + jitter, honor Retry-After, and pair retries with idempotency.
A gateway spends most of its life waiting on the network. Because this work is I/O-bound rather than CPU-bound, asyncio is the natural fit: one event loop keeps hundreds of in-flight requests alive, switching to whichever has data ready. Two libraries dominate async HTTP in Python:
httpx
aiohttp
Sync + async?
Both, one library
Async-only
API style
Requests-compatible
Session-based
Default timeout
5s (connect/read/write/pool)
None by default
Pool config
httpx.Limits
TCPConnector
Per-host cap
No
Yes (limit_per_host)
The canonical concurrent-fan-out pattern combines a shared client with asyncio.gather:
import asyncio
import httpx
async def fetch(client: httpx.AsyncClient, url: str):
r = await client.get(url, timeout=10.0)
r.raise_for_status()
return r.json()
async def fetch_all(urls: list[str]):
async with httpx.AsyncClient() as client:
return await asyncio.gather(*(fetch(client, u) for u in urls))
Recall that opening a new TCP connection — and, over HTTPS, a full TLS handshake — is expensive. A connection pool amortizes that cost by reusing established connections; this reuse is HTTP keep-alive. The single most important rule: instantiate one long-lived client and reuse it everywhere. Creating a new client per request defeats pooling entirely and leaks connections. In httpx, the pool is tuned with httpx.Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=5.0). In aiohttp, the same knobs live on the TCPConnector, whose limit_per_host keeps you a polite client to a rate-limited API.
Timeouts are non-negotiable: never issue a network request without one. httpx applies a 5-second default independently to four phases — connect, read, write, and pool. aiohttp uses ClientTimeout with one subtlety: its clock starts the moment session.get() is called, so time spent queued waiting for a free connection counts against the timeout.
Concern
httpx
aiohttp
Timeout defaults
5s per phase
None — you must set it
Timeout clock starts
Per-phase
At session.get(), includes pool wait
Total pool cap
Limits(max_connections=...)
TCPConnector(limit=...)
Per-host cap
—
TCPConnector(limit_per_host=...)
Upstreams fail transiently. Retrying blindly and immediately makes things worse: a synchronized retry storm can knock a recovering service back over. The standard remedy is exponential backoff — wait progressively longer (1s, 2s, 4s, 8s) — plus jitter, a small random offset so many clients don't align into synchronized waves. Two rules keep retries safe: only retry retryable errors (429, 500, 502, 503, 504, connection/timeout failures — never a 400 or 401), and pair retries with idempotency.
import asyncio
import random
import httpx
RETRYABLE = {429, 500, 502, 503, 504}
async def post_with_backoff(client, url, json, max_attempts=5):
for attempt in range(max_attempts):
try:
r = await client.post(url, json=json, timeout=10.0)
if r.status_code not in RETRYABLE:
r.raise_for_status() # 2xx succeeds; 4xx fails fast
return r.json()
# honor Retry-After on 429 if present
wait = float(r.headers.get("Retry-After", 2 ** attempt))
except (httpx.ConnectError, httpx.ReadTimeout):
wait = 2 ** attempt
wait += random.uniform(0, 1) # jitter
await asyncio.sleep(wait)
raise RuntimeError(f"exhausted retries for {url}")
Visual animation — coming soon
Post-Reading Check — Async HTTP Clients
Why is asyncio a natural fit for a gateway's outbound HTTP work?
The work is CPU-bound, so async spreads it across cores
The work is I/O-bound, so one event loop can keep hundreds of in-flight requests alive while waiting
Async removes the need for timeouts entirely
Async guarantees requests never fail
What is the single most important rule for preserving connection pooling and keep-alive?
Create a new AsyncClient or ClientSession for every request
Instantiate one long-lived client and reuse it everywhere
Disable keep-alive so connections close immediately
Open a fresh TLS handshake before each call
What is a key difference in timeout behavior between httpx and aiohttp?
httpx has no default timeout, while aiohttp defaults to 5s
Both apply a 30-second default and cannot be changed
httpx defaults to 5s per phase; aiohttp has none and counts pool-queue wait against the clock
Neither library supports configurable timeouts
Which set of errors is safe to retry with exponential backoff?
400 and 401, because the request is malformed
429, 500, 502, 503, 504, and connection/timeout failures
Only 200 responses
Every 4xx error should be retried aggressively
Why add jitter to an exponential-backoff retry schedule?
It makes the retries happen sooner
It encrypts the retry request
A small random offset prevents many clients from aligning into synchronized retry waves
It converts a POST into an idempotent request
Pre-Reading Check — Async HTTP Servers
What is ASGI, and how does it relate to WSGI?
ASGI is the synchronous predecessor to WSGI
ASGI is the async successor to WSGI, defining an app callable of scope, receive, and send
ASGI and WSGI are the same interface with different names
ASGI is a database driver; WSGI is a web server
How does a single uvicorn process achieve concurrency?
By spawning one thread per incoming request
By running one cooperative event loop that switches tasks whenever a handler awaits on I/O
By using multiple CPU cores within one process automatically
By blocking on each request until it completes
Why must you use an async HTTP client like httpx inside an async handler?
Blocking clients are not installable alongside FastAPI
A blocking call in the single-threaded loop stalls every other request, negating ASGI's concurrency
Async clients are the only ones that support HTTPS
FastAPI rejects any handler that calls a synchronous library
How do you use all CPU cores with uvicorn?
A single uvicorn process automatically uses every core
Run multiple uvicorn workers (often under Gunicorn), each an independent event loop on its own core
Increase the event loop's thread count with a flag
Switch from ASGI to WSGI to unlock multiprocessing
What is the "fast-ack" pattern for a webhook endpoint?
Run the slow LLM call first, then acknowledge once you have a full answer
Verify the signature, enqueue the event, and return 200 immediately, deferring slow work to a background worker
Return 202 Accepted and never process the event
Hold the connection open until processing finishes to avoid retries
Async HTTP Servers
Key Points
ASGI is the async successor to synchronous WSGI, defining an app callable of scope, receive, and send, and natively supporting WebSockets. FastAPI (on Starlette) is what you write against.
Uvicorn runs one event loop per process, made fast by uvloop and httptools; concurrency is cooperative.
Because the loop is single-threaded, a blocking call stalls every request — async clients are mandatory inside handlers.
Scale across cores with multiple uvicorn workers, typically under Gunicorn: async concurrency within a worker, process parallelism across workers.
Adopt fast-ack: verify, enqueue, and return 200 within a few seconds — or the platform retries and causes duplicates.
To receive webhooks and serve API endpoints, the gateway needs an HTTP server that holds many connections open without a thread per connection. That comes from the ASGI stack: a framework like FastAPI running on a server like uvicorn.
ASGI (Asynchronous Server Gateway Interface) is the standard contract between a Python web server and an async application — the asynchronous successor to WSGI, the older synchronous standard used by Flask and Django. Under WSGI, each request occupies a worker thread for its entire lifetime; handling N slow requests concurrently needs roughly N threads. ASGI removes that constraint with an async interface and natively supports WebSockets and server-sent events. Concretely, an ASGI application is an async callable receiving scope (a dict describing the connection) plus two coroutine channels, receive and send. You rarely write ASGI by hand: FastAPI is built on Starlette, which supplies routing, middleware, and WebSocket support.
Uvicorn is a lightweight, high-performance ASGI server built on asyncio. Its speed comes from two C-level libraries: uvloop, a drop-in replacement for the standard event loop that runs roughly 2–4× faster, and httptools for fast HTTP parsing. Here is a complete minimal server that verifies a Slack signature and responds:
from fastapi import FastAPI, Request, HTTPException
import os
app = FastAPI()
SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
@app.post("/slack/events")
async def slack_events(request: Request):
# Read the RAW body first — before any JSON parsing
body = await request.body()
ts = request.headers.get("X-Slack-Request-Timestamp", "0")
sig = request.headers.get("X-Slack-Signature", "")
if not verify_slack(SIGNING_SECRET, ts, body, sig):
raise HTTPException(status_code=401, detail="bad signature")
payload = await request.json() # safe to parse now
# ... hand off to a queue (Chapter 8) instead of processing here ...
return {"ok": True} # fast acknowledgement
Run it with uvicorn main:app --host 0.0.0.0 --port 8000. A single uvicorn process runs one event loop on one CPU core. Concurrency is cooperative: when a handler hits an await on I/O, it yields to the event loop, which immediately runs another ready task. The critical caveat: the loop is single-threaded, so a CPU-bound or blocking call stalls every other request. This is exactly why you must use an async client like httpx inside async handlers.
To use all CPU cores and add fault isolation, run multiple uvicorn workers, commonly supervised by Gunicorn (gunicorn -k uvicorn.workers.UvicornWorker -w 4). This two-level model — async concurrency within a worker, process parallelism across workers — is the standard production deployment.
Figure 4.2: Two levels of concurrency.
flowchart LR
LB["Load balancer"] --> W1["Uvicorn worker 1 (CPU core 1)"]
LB --> W2["Uvicorn worker 2 (CPU core 2)"]
LB --> W3["Uvicorn worker 3 (CPU core 3)"]
LB --> W4["Uvicorn worker 4 (CPU core 4)"]
subgraph EL["Inside worker 1: one event loop"]
direction LR
R1["Request A: await on I/O"]
R2["Request B: await on I/O"]
R3["Request C: ready to run"]
end
W1 --> EL
Visual animation — coming soon
Chat platforms impose tight acknowledgement deadlines — Slack expects a response within about three seconds and retries a slow reply. If your endpoint runs a slow LLM call before responding, you will blow the deadline and the retry triggers duplicate processing. The correct pattern is fast-ack: verify the signature, do the minimum work to enqueue the event, and return 200 immediately. The heavy lifting happens afterward in a background worker consuming from a queue.
Post-Reading Check — Async HTTP Servers
What is ASGI, and how does it relate to WSGI?
ASGI is the synchronous predecessor to WSGI
ASGI is the async successor to WSGI, defining an app callable of scope, receive, and send
ASGI and WSGI are the same interface with different names
ASGI is a database driver; WSGI is a web server
How does a single uvicorn process achieve concurrency?
By spawning one thread per incoming request
By running one cooperative event loop that switches tasks whenever a handler awaits on I/O
By using multiple CPU cores within one process automatically
By blocking on each request until it completes
Why must you use an async HTTP client like httpx inside an async handler?
Blocking clients are not installable alongside FastAPI
A blocking call in the single-threaded loop stalls every other request, negating ASGI's concurrency
Async clients are the only ones that support HTTPS
FastAPI rejects any handler that calls a synchronous library
How do you use all CPU cores with uvicorn?
A single uvicorn process automatically uses every core
Run multiple uvicorn workers (often under Gunicorn), each an independent event loop on its own core
Increase the event loop's thread count with a flag
Switch from ASGI to WSGI to unlock multiprocessing
What is the "fast-ack" pattern for a webhook endpoint?
Run the slow LLM call first, then acknowledge once you have a full answer
Verify the signature, enqueue the event, and return 200 immediately, deferring slow work to a background worker
Return 202 Accepted and never process the event
Hold the connection open until processing finishes to avoid retries