Skip to content

Commit 6f4f83f

Browse files
[PR #10945/18785096 backport][3.12] Add Client Middleware Cookbook (#10969)
Co-authored-by: J. Nick Koston <[email protected]>
1 parent 82497a6 commit 6f4f83f

10 files changed

+1623
-0
lines changed

CHANGES/10945.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
9732.feature.rst

docs/client.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The page contains all information about aiohttp Client API:
1414

1515
Quickstart <client_quickstart>
1616
Advanced Usage <client_advanced>
17+
Client Middleware Cookbook <client_middleware_cookbook>
1718
Reference <client_reference>
1819
Tracing Reference <tracing_reference>
1920
The aiohttp Request Lifecycle <http_request_lifecycle>

docs/client_advanced.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ Client Middleware
126126
The client supports middleware to intercept requests and responses. This can be
127127
useful for authentication, logging, request/response modification, and retries.
128128

129+
For practical examples and common middleware patterns, see the :ref:`aiohttp-client-middleware-cookbook`.
130+
129131
Creating Middleware
130132
^^^^^^^^^^^^^^^^^^^
131133

docs/client_middleware_cookbook.rst

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
.. currentmodule:: aiohttp
2+
3+
.. _aiohttp-client-middleware-cookbook:
4+
5+
Client Middleware Cookbook
6+
==========================
7+
8+
This cookbook provides practical examples of implementing client middleware for common use cases.
9+
10+
.. note::
11+
12+
All examples in this cookbook are also available as complete, runnable scripts in the
13+
``examples/`` directory of the aiohttp repository. Look for files named ``*_middleware.py``.
14+
15+
.. _cookbook-basic-auth-middleware:
16+
17+
Basic Authentication Middleware
18+
-------------------------------
19+
20+
Basic authentication is a simple authentication scheme built into the HTTP protocol.
21+
Here's a middleware that automatically adds Basic Auth headers to all requests:
22+
23+
.. code-block:: python
24+
25+
import base64
26+
from aiohttp import ClientRequest, ClientResponse, ClientHandlerType, hdrs
27+
28+
class BasicAuthMiddleware:
29+
"""Middleware that adds Basic Authentication to all requests."""
30+
31+
def __init__(self, username: str, password: str) -> None:
32+
self.username = username
33+
self.password = password
34+
self._auth_header = self._encode_credentials()
35+
36+
def _encode_credentials(self) -> str:
37+
"""Encode username and password to base64."""
38+
credentials = f"{self.username}:{self.password}"
39+
encoded = base64.b64encode(credentials.encode()).decode()
40+
return f"Basic {encoded}"
41+
42+
async def __call__(
43+
self,
44+
request: ClientRequest,
45+
handler: ClientHandlerType
46+
) -> ClientResponse:
47+
"""Add Basic Auth header to the request."""
48+
# Only add auth if not already present
49+
if hdrs.AUTHORIZATION not in request.headers:
50+
request.headers[hdrs.AUTHORIZATION] = self._auth_header
51+
52+
# Proceed with the request
53+
return await handler(request)
54+
55+
Usage example:
56+
57+
.. code-block:: python
58+
59+
import aiohttp
60+
import asyncio
61+
import logging
62+
63+
_LOGGER = logging.getLogger(__name__)
64+
65+
async def main():
66+
# Create middleware instance
67+
auth_middleware = BasicAuthMiddleware("user", "pass")
68+
69+
# Use middleware in session
70+
async with aiohttp.ClientSession(middlewares=(auth_middleware,)) as session:
71+
async with session.get("https://httpbin.org/basic-auth/user/pass") as resp:
72+
_LOGGER.debug("Status: %s", resp.status)
73+
data = await resp.json()
74+
_LOGGER.debug("Response: %s", data)
75+
76+
asyncio.run(main())
77+
78+
.. _cookbook-retry-middleware:
79+
80+
Simple Retry Middleware
81+
-----------------------
82+
83+
A retry middleware that automatically retries failed requests with exponential backoff:
84+
85+
.. code-block:: python
86+
87+
import asyncio
88+
import logging
89+
from http import HTTPStatus
90+
from typing import Union, Set
91+
from aiohttp import ClientRequest, ClientResponse, ClientHandlerType
92+
93+
_LOGGER = logging.getLogger(__name__)
94+
95+
DEFAULT_RETRY_STATUSES = {
96+
HTTPStatus.TOO_MANY_REQUESTS,
97+
HTTPStatus.INTERNAL_SERVER_ERROR,
98+
HTTPStatus.BAD_GATEWAY,
99+
HTTPStatus.SERVICE_UNAVAILABLE,
100+
HTTPStatus.GATEWAY_TIMEOUT
101+
}
102+
103+
class RetryMiddleware:
104+
"""Middleware that retries failed requests with exponential backoff."""
105+
106+
def __init__(
107+
self,
108+
max_retries: int = 3,
109+
retry_statuses: Union[Set[int], None] = None,
110+
initial_delay: float = 1.0,
111+
backoff_factor: float = 2.0
112+
) -> None:
113+
self.max_retries = max_retries
114+
self.retry_statuses = retry_statuses or DEFAULT_RETRY_STATUSES
115+
self.initial_delay = initial_delay
116+
self.backoff_factor = backoff_factor
117+
118+
async def __call__(
119+
self,
120+
request: ClientRequest,
121+
handler: ClientHandlerType
122+
) -> ClientResponse:
123+
"""Execute request with retry logic."""
124+
last_response = None
125+
delay = self.initial_delay
126+
127+
for attempt in range(self.max_retries + 1):
128+
if attempt > 0:
129+
_LOGGER.info(
130+
"Retrying request to %s (attempt %s/%s)",
131+
request.url,
132+
attempt + 1,
133+
self.max_retries + 1
134+
)
135+
136+
# Execute the request
137+
response = await handler(request)
138+
last_response = response
139+
140+
# Check if we should retry
141+
if response.status not in self.retry_statuses:
142+
return response
143+
144+
# Don't retry if we've exhausted attempts
145+
if attempt >= self.max_retries:
146+
_LOGGER.warning(
147+
"Max retries (%s) exceeded for %s",
148+
self.max_retries,
149+
request.url
150+
)
151+
return response
152+
153+
# Wait before retrying
154+
_LOGGER.debug("Waiting %ss before retry...", delay)
155+
await asyncio.sleep(delay)
156+
delay *= self.backoff_factor
157+
158+
# Return the last response
159+
return last_response
160+
161+
Usage example:
162+
163+
.. code-block:: python
164+
165+
import aiohttp
166+
import asyncio
167+
import logging
168+
from http import HTTPStatus
169+
170+
_LOGGER = logging.getLogger(__name__)
171+
172+
RETRY_STATUSES = {
173+
HTTPStatus.TOO_MANY_REQUESTS,
174+
HTTPStatus.INTERNAL_SERVER_ERROR,
175+
HTTPStatus.BAD_GATEWAY,
176+
HTTPStatus.SERVICE_UNAVAILABLE,
177+
HTTPStatus.GATEWAY_TIMEOUT
178+
}
179+
180+
async def main():
181+
# Create retry middleware with custom settings
182+
retry_middleware = RetryMiddleware(
183+
max_retries=3,
184+
retry_statuses=RETRY_STATUSES,
185+
initial_delay=0.5,
186+
backoff_factor=2.0
187+
)
188+
189+
async with aiohttp.ClientSession(middlewares=(retry_middleware,)) as session:
190+
# This will automatically retry on server errors
191+
async with session.get("https://httpbin.org/status/500") as resp:
192+
_LOGGER.debug("Final status: %s", resp.status)
193+
194+
asyncio.run(main())
195+
196+
.. _cookbook-combining-middleware:
197+
198+
Combining Multiple Middleware
199+
-----------------------------
200+
201+
You can combine multiple middleware to create powerful request pipelines:
202+
203+
.. code-block:: python
204+
205+
import time
206+
import logging
207+
from aiohttp import ClientRequest, ClientResponse, ClientHandlerType
208+
209+
_LOGGER = logging.getLogger(__name__)
210+
211+
class LoggingMiddleware:
212+
"""Middleware that logs request timing and response status."""
213+
214+
async def __call__(
215+
self,
216+
request: ClientRequest,
217+
handler: ClientHandlerType
218+
) -> ClientResponse:
219+
start_time = time.monotonic()
220+
221+
# Log request
222+
_LOGGER.debug("[REQUEST] %s %s", request.method, request.url)
223+
224+
# Execute request
225+
response = await handler(request)
226+
227+
# Log response
228+
duration = time.monotonic() - start_time
229+
_LOGGER.debug("[RESPONSE] %s in %.2fs", response.status, duration)
230+
231+
return response
232+
233+
# Combine multiple middleware
234+
async def main():
235+
# Middleware are applied in order: logging -> auth -> retry -> request
236+
logging_middleware = LoggingMiddleware()
237+
auth_middleware = BasicAuthMiddleware("user", "pass")
238+
retry_middleware = RetryMiddleware(max_retries=2)
239+
240+
async with aiohttp.ClientSession(
241+
middlewares=(logging_middleware, auth_middleware, retry_middleware)
242+
) as session:
243+
async with session.get("https://httpbin.org/basic-auth/user/pass") as resp:
244+
text = await resp.text()
245+
_LOGGER.debug("Response text: %s", text)
246+
247+
.. _cookbook-token-refresh-middleware:
248+
249+
Token Refresh Middleware
250+
------------------------
251+
252+
A more advanced example showing JWT token refresh:
253+
254+
.. code-block:: python
255+
256+
import asyncio
257+
import time
258+
from http import HTTPStatus
259+
from typing import Union
260+
from aiohttp import ClientRequest, ClientResponse, ClientHandlerType, hdrs
261+
262+
class TokenRefreshMiddleware:
263+
"""Middleware that handles JWT token refresh automatically."""
264+
265+
def __init__(self, token_endpoint: str, refresh_token: str) -> None:
266+
self.token_endpoint = token_endpoint
267+
self.refresh_token = refresh_token
268+
self.access_token: Union[str, None] = None
269+
self.token_expires_at: Union[float, None] = None
270+
self._refresh_lock = asyncio.Lock()
271+
272+
async def _refresh_access_token(self, session) -> str:
273+
"""Refresh the access token using the refresh token."""
274+
async with self._refresh_lock:
275+
# Check if another coroutine already refreshed the token
276+
if self.token_expires_at and time.time() < self.token_expires_at:
277+
return self.access_token
278+
279+
# Make refresh request without middleware to avoid recursion
280+
async with session.post(
281+
self.token_endpoint,
282+
json={"refresh_token": self.refresh_token},
283+
middlewares=() # Disable middleware for this request
284+
) as resp:
285+
resp.raise_for_status()
286+
data = await resp.json()
287+
288+
if "access_token" not in data:
289+
raise ValueError("No access_token in refresh response")
290+
291+
self.access_token = data["access_token"]
292+
# Token expires in 1 hour for demo, refresh 5 min early
293+
expires_in = data.get("expires_in", 3600)
294+
self.token_expires_at = time.time() + expires_in - 300
295+
return self.access_token
296+
297+
async def __call__(
298+
self,
299+
request: ClientRequest,
300+
handler: ClientHandlerType
301+
) -> ClientResponse:
302+
"""Add auth token to request, refreshing if needed."""
303+
# Skip token for refresh endpoint
304+
if str(request.url).endswith('/token/refresh'):
305+
return await handler(request)
306+
307+
# Refresh token if needed
308+
if not self.access_token or (
309+
self.token_expires_at and time.time() >= self.token_expires_at
310+
):
311+
await self._refresh_access_token(request.session)
312+
313+
# Add token to request
314+
request.headers[hdrs.AUTHORIZATION] = f"Bearer {self.access_token}"
315+
316+
# Execute request
317+
response = await handler(request)
318+
319+
# If we get 401, try refreshing token once
320+
if response.status == HTTPStatus.UNAUTHORIZED:
321+
await self._refresh_access_token(request.session)
322+
request.headers[hdrs.AUTHORIZATION] = f"Bearer {self.access_token}"
323+
response = await handler(request)
324+
325+
return response
326+
327+
Best Practices
328+
--------------
329+
330+
1. **Keep middleware focused**: Each middleware should have a single responsibility.
331+
332+
2. **Order matters**: Middleware execute in the order they're listed. Place logging first,
333+
authentication before retry, etc.
334+
335+
3. **Avoid infinite recursion**: When making HTTP requests inside middleware, either:
336+
337+
- Use ``middlewares=()`` to disable middleware for internal requests
338+
- Check the request URL/host to skip middleware for specific endpoints
339+
- Use a separate session for internal requests
340+
341+
4. **Handle errors gracefully**: Don't let middleware errors break the request flow unless
342+
absolutely necessary.
343+
344+
5. **Use bounded loops**: Always use ``for`` loops with a maximum iteration count instead
345+
of unbounded ``while`` loops to prevent infinite retries.
346+
347+
6. **Consider performance**: Each middleware adds overhead. For simple cases like adding
348+
static headers, consider using session or request parameters instead.
349+
350+
7. **Test thoroughly**: Middleware can affect all requests in subtle ways. Test edge cases
351+
like network errors, timeouts, and concurrent requests.
352+
353+
See Also
354+
--------
355+
356+
- :ref:`aiohttp-client-middleware` - Core middleware documentation
357+
- :ref:`aiohttp-client-advanced` - Advanced client usage
358+
- :class:`DigestAuthMiddleware` - Built-in digest authentication middleware

docs/spelling_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ autoformatters
2828
autogenerates
2929
autogeneration
3030
awaitable
31+
backoff
3132
backend
3233
backends
3334
backport

0 commit comments

Comments
 (0)