Skip to content

Commit db5c238

Browse files
Allow caching of middleware construction (aio-libs#10890)
1 parent e1a4841 commit db5c238

9 files changed

Lines changed: 281 additions & 100 deletions

CHANGES/10890.misc.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improved client middleware performance by allowing the chain of middlewares to be cached across requests -- by :user:`Dreamsorcerer`.

aiohttp/client.py

Lines changed: 46 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,15 @@
6868
WSMessageTypeError,
6969
WSServerHandshakeError,
7070
)
71-
from .client_middlewares import ClientMiddlewareType, build_client_middlewares
71+
from .client_middlewares import ClientMiddlewareType, _cached_build_client_middlewares
7272
from .client_reqrep import (
7373
SSL_ALLOWED_TYPES,
7474
ClientRequest,
7575
ClientResponse,
76+
ClientTimeout,
7677
Fingerprint,
7778
RequestInfo,
79+
ResponseParams,
7880
)
7981
from .client_ws import (
8082
DEFAULT_WS_CLIENT_TIMEOUT,
@@ -90,7 +92,6 @@
9092
HTTP_AND_EMPTY_SCHEMA_SET,
9193
TimeoutHandle,
9294
_auth_header_from_netrc,
93-
frozen_dataclass_decorator,
9495
get_env_proxy_for_url,
9596
netrc_from_env,
9697
sentinel,
@@ -214,52 +215,6 @@ class _WSConnectOptions(TypedDict, total=False):
214215
max_msg_size: int
215216

216217

217-
@frozen_dataclass_decorator
218-
class ClientTimeout:
219-
total: float | None = 5 * 60 # 5 minute default timeout
220-
connect: float | None = None
221-
sock_read: float | None = None
222-
sock_connect: float | None = None
223-
ceil_threshold: float = 5
224-
225-
# pool_queue_timeout: Optional[float] = None
226-
# dns_resolution_timeout: Optional[float] = None
227-
# socket_connect_timeout: Optional[float] = None
228-
# connection_acquiring_timeout: Optional[float] = None
229-
# new_connection_timeout: Optional[float] = None
230-
# http_header_timeout: Optional[float] = None
231-
# response_body_timeout: Optional[float] = None
232-
233-
# to create a timeout specific for a single request, either
234-
# - create a completely new one to overwrite the default
235-
# - or use https://docs.python.org/3/library/dataclasses.html#dataclasses.replace
236-
# to overwrite the defaults
237-
238-
def __post_init__(self) -> None:
239-
# Ensure total is never lower than a more specific timeout, otherwise
240-
# the latter would be silently capped by total and rendered useless.
241-
# total=None means the user explicitly disabled the total timeout.
242-
if self.total is None:
243-
return
244-
object.__setattr__(
245-
self,
246-
"total",
247-
max(
248-
self.total,
249-
self.connect or 0,
250-
self.sock_read or 0,
251-
self.sock_connect or 0,
252-
),
253-
)
254-
255-
if self.total == 0:
256-
raise ValueError(
257-
"total timeout must be a positive number or None to disable, "
258-
"got 0. Using 0 to disable timeouts is no longer supported, "
259-
"use None instead."
260-
)
261-
262-
263218
# https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2
264219
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"})
265220

@@ -271,6 +226,31 @@ def __post_init__(self) -> None:
271226
_CharsetResolver = Callable[[ClientResponse, bytes], str]
272227

273228

229+
# Module-level (not a closure) so it has a stable identity for the
230+
# ``_cached_build_client_middlewares`` cache key.
231+
async def _connect_and_send_request(req: ClientRequest) -> ClientResponse:
232+
connector = req._session._connector
233+
assert connector is not None
234+
try:
235+
conn = await connector.connect(req, traces=req._traces, timeout=req._timeout)
236+
except asyncio.TimeoutError as exc:
237+
raise ConnectionTimeoutError(f"Connection timeout to host {req.url}") from exc
238+
239+
assert conn.protocol is not None
240+
conn.protocol.set_response_params(**req._response_params)
241+
try:
242+
resp = await req._send(conn)
243+
try:
244+
await resp.start(conn)
245+
except BaseException:
246+
resp.close()
247+
raise
248+
except BaseException:
249+
conn.close()
250+
raise
251+
return resp
252+
253+
274254
@final
275255
class ClientSession:
276256
"""First-class interface for making HTTP requests."""
@@ -428,7 +408,7 @@ def __init__(
428408

429409
self._default_proxy = proxy
430410
self._retry_connection: bool = True
431-
self._middlewares = middlewares
411+
self._middlewares = tuple(middlewares)
432412

433413
def __init_subclass__(cls: type["ClientSession"]) -> None:
434414
raise TypeError(
@@ -674,6 +654,19 @@ async def _request(
674654
{hdrs.PROXY_AUTHORIZATION: env_proxy_auth}
675655
)
676656

657+
response_params: ResponseParams = {
658+
"timer": timer,
659+
"skip_payload": method in EMPTY_BODY_METHODS,
660+
"read_until_eof": read_until_eof,
661+
"auto_decompress": auto_decompress,
662+
"read_timeout": real_timeout.sock_read,
663+
"read_bufsize": read_bufsize,
664+
"timeout_ceil_threshold": self._connector._timeout_ceil_threshold,
665+
"max_line_size": max_line_size,
666+
"max_field_size": max_field_size,
667+
"max_headers": max_headers,
668+
}
669+
677670
req = self._request_class(
678671
method,
679672
url,
@@ -689,7 +682,9 @@ async def _request(
689682
loop=self._loop,
690683
response_class=self._response_class,
691684
proxy=proxy_,
685+
response_params=response_params,
692686
timer=timer,
687+
timeout=real_timeout,
693688
session=self,
694689
ssl=ssl,
695690
server_hostname=server_hostname,
@@ -698,52 +693,13 @@ async def _request(
698693
trust_env=self.trust_env,
699694
)
700695

701-
async def _connect_and_send_request(
702-
req: ClientRequest,
703-
) -> ClientResponse:
704-
# connection timeout
705-
assert self._connector is not None
706-
try:
707-
conn = await self._connector.connect(
708-
req, traces=traces, timeout=real_timeout
709-
)
710-
except asyncio.TimeoutError as exc:
711-
raise ConnectionTimeoutError(
712-
f"Connection timeout to host {req.url}"
713-
) from exc
714-
715-
assert conn.protocol is not None
716-
conn.protocol.set_response_params(
717-
timer=timer,
718-
skip_payload=req.method in EMPTY_BODY_METHODS,
719-
read_until_eof=read_until_eof,
720-
auto_decompress=auto_decompress,
721-
read_timeout=real_timeout.sock_read,
722-
read_bufsize=read_bufsize,
723-
timeout_ceil_threshold=self._connector._timeout_ceil_threshold,
724-
max_line_size=max_line_size,
725-
max_field_size=max_field_size,
726-
max_headers=max_headers,
727-
)
728-
try:
729-
resp = await req._send(conn)
730-
try:
731-
await resp.start(conn)
732-
except BaseException:
733-
resp.close()
734-
raise
735-
except BaseException:
736-
conn.close()
737-
raise
738-
return resp
739-
740696
# Apply middleware (if any) - per-request middleware overrides session middleware
741697
effective_middlewares = (
742-
self._middlewares if middlewares is None else middlewares
698+
self._middlewares if middlewares is None else tuple(middlewares)
743699
)
744700

745701
if effective_middlewares:
746-
handler = build_client_middlewares(
702+
handler = _cached_build_client_middlewares(
747703
_connect_and_send_request, effective_middlewares
748704
)
749705
else:

aiohttp/client_middlewares.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Client middleware support."""
22

3-
from collections.abc import Awaitable, Callable, Sequence
3+
from collections.abc import Awaitable, Callable
4+
from functools import lru_cache
45

56
from .client_reqrep import ClientRequest, ClientResponse
67

@@ -17,16 +18,15 @@
1718

1819
def build_client_middlewares(
1920
handler: ClientHandlerType,
20-
middlewares: Sequence[ClientMiddlewareType],
21+
middlewares: tuple[ClientMiddlewareType, ...],
2122
) -> ClientHandlerType:
2223
"""
2324
Apply middlewares to request handler.
2425
2526
The middlewares are applied in reverse order, so the first middleware
2627
in the list wraps all subsequent middlewares and the handler.
2728
28-
This implementation avoids using partial/update_wrapper to minimize overhead
29-
and doesn't cache to avoid holding references to stateful middleware.
29+
This implementation avoids using partial/update_wrapper to minimize overhead.
3030
"""
3131
# Optimize for single middleware case
3232
if len(middlewares) == 1:
@@ -53,3 +53,6 @@ async def wrapped(req: ClientRequest) -> ClientResponse:
5353
current_handler = make_wrapper(middleware, current_handler)
5454

5555
return current_handler
56+
57+
58+
_cached_build_client_middlewares = lru_cache(maxsize=64)(build_client_middlewares)

aiohttp/client_reqrep.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,52 @@
8383
_DIGITS_RE = re.compile(r"\d+", re.ASCII)
8484

8585

86+
@frozen_dataclass_decorator
87+
class ClientTimeout:
88+
total: float | None = 5 * 60 # 5 minute default timeout
89+
connect: float | None = None
90+
sock_read: float | None = None
91+
sock_connect: float | None = None
92+
ceil_threshold: float = 5
93+
94+
# pool_queue_timeout: Optional[float] = None
95+
# dns_resolution_timeout: Optional[float] = None
96+
# socket_connect_timeout: Optional[float] = None
97+
# connection_acquiring_timeout: Optional[float] = None
98+
# new_connection_timeout: Optional[float] = None
99+
# http_header_timeout: Optional[float] = None
100+
# response_body_timeout: Optional[float] = None
101+
102+
# to create a timeout specific for a single request, either
103+
# - create a completely new one to overwrite the default
104+
# - or use https://docs.python.org/3/library/dataclasses.html#dataclasses.replace
105+
# to overwrite the defaults
106+
107+
def __post_init__(self) -> None:
108+
# Ensure total is never lower than a more specific timeout, otherwise
109+
# the latter would be silently capped by total and rendered useless.
110+
# total=None means the user explicitly disabled the total timeout.
111+
if self.total is None:
112+
return
113+
object.__setattr__(
114+
self,
115+
"total",
116+
max(
117+
self.total,
118+
self.connect or 0,
119+
self.sock_read or 0,
120+
self.sock_connect or 0,
121+
),
122+
)
123+
124+
if self.total == 0:
125+
raise ValueError(
126+
"total timeout must be a positive number or None to disable, "
127+
"got 0. Using 0 to disable timeouts is no longer supported, "
128+
"use None instead."
129+
)
130+
131+
86132
def _gen_default_accept_encoding() -> str:
87133
encodings = [
88134
"gzip",
@@ -184,6 +230,19 @@ class ConnectionKey(NamedTuple):
184230
server_hostname: str | None = None
185231

186232

233+
class ResponseParams(TypedDict):
234+
timer: BaseTimerContext | None
235+
skip_payload: bool
236+
read_until_eof: bool
237+
auto_decompress: bool
238+
read_timeout: float | None
239+
read_bufsize: int
240+
timeout_ceil_threshold: float
241+
max_line_size: int
242+
max_field_size: int
243+
max_headers: int
244+
245+
187246
class ClientResponse(HeadersMixin):
188247
# Some of these attributes are None when created,
189248
# but will be set by the start() method.
@@ -955,7 +1014,9 @@ class ClientRequestArgs(TypedDict, total=False):
9551014
loop: asyncio.AbstractEventLoop
9561015
response_class: type[ClientResponse]
9571016
proxy: URL | None
1017+
response_params: ResponseParams
9581018
timer: BaseTimerContext
1019+
timeout: ClientTimeout
9591020
session: "ClientSession"
9601021
ssl: SSLContext | bool | Fingerprint
9611022
proxy_headers: CIMultiDict[str] | None
@@ -968,6 +1029,10 @@ class ClientRequest(ClientRequestBase):
9681029
_EMPTY_BODY = payload.PAYLOAD_REGISTRY.get(b"", disposition=None)
9691030
_body = _EMPTY_BODY
9701031
_continue = None # waiter future for '100 Continue' response
1032+
_response_params: ResponseParams = None # type: ignore[assignment]
1033+
_session: "ClientSession" = None # type: ignore[assignment]
1034+
_timeout = ClientTimeout()
1035+
_traces: list["Trace"] = () # type: ignore[assignment]
9711036

9721037
GET_METHODS = {
9731038
hdrs.METH_GET,
@@ -997,7 +1062,9 @@ def __init__(
9971062
loop: asyncio.AbstractEventLoop,
9981063
response_class: type[ClientResponse],
9991064
proxy: URL | None,
1065+
response_params: ResponseParams,
10001066
timer: BaseTimerContext,
1067+
timeout: ClientTimeout,
10011068
session: "ClientSession",
10021069
ssl: SSLContext | bool | Fingerprint,
10031070
proxy_headers: CIMultiDict[str] | None,
@@ -1021,7 +1088,9 @@ def __init__(
10211088
self._session = session
10221089
self.chunked = chunked
10231090
self.response_class = response_class
1091+
self._response_params = response_params
10241092
self._timer = timer
1093+
self._timeout = timeout
10251094
self.server_hostname = server_hostname
10261095
self.version = version
10271096

tests/conftest.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
except ImportError: # For downstreams only # pragma: no cover
3333
HAS_BLOCKBUSTER = False
3434

35-
from aiohttp.client import ClientSession
35+
from aiohttp.client import ClientSession, ClientTimeout
3636
from aiohttp.client_proto import ResponseHandler
3737
from aiohttp.client_reqrep import ClientRequest, ClientRequestArgs, ClientResponse
3838
from aiohttp.compression_utils import ZLibBackend, ZLibBackendProtocol, set_zlib_backend
@@ -439,6 +439,8 @@ def maker(
439439
) -> ClientRequest:
440440
session = ClientSession()
441441
sessions.append(session)
442+
timer = TimerNoop()
443+
timeout = ClientTimeout()
442444
default_args: ClientRequestArgs = {
443445
"loop": asyncio.get_running_loop(),
444446
"params": {},
@@ -452,7 +454,20 @@ def maker(
452454
"expect100": False,
453455
"response_class": ClientResponse,
454456
"proxy": None,
455-
"timer": TimerNoop(),
457+
"response_params": {
458+
"timer": timer,
459+
"skip_payload": True,
460+
"read_until_eof": True,
461+
"auto_decompress": True,
462+
"read_timeout": timeout.sock_read,
463+
"read_bufsize": 2**16,
464+
"timeout_ceil_threshold": 5,
465+
"max_line_size": 8190,
466+
"max_field_size": 8190,
467+
"max_headers": 128,
468+
},
469+
"timer": timer,
470+
"timeout": timeout,
456471
"session": session,
457472
"ssl": True,
458473
"proxy_headers": None,

0 commit comments

Comments
 (0)