Skip to content

Commit c265228

Browse files
authored
[PR #10991/452458a backport][3.12] Optimize small HTTP requests/responses by coalescing headers and body into a single packet (#10992)
1 parent 560ffbf commit c265228

11 files changed

+1099
-27
lines changed

CHANGES/10991.feature.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Optimized small HTTP requests/responses by coalescing headers and body into a single TCP packet -- by :user:`bdraco`.
2+
3+
This change enhances network efficiency by reducing the number of packets sent for small HTTP payloads, improving latency and reducing overhead. Most importantly, this fixes compatibility with memory-constrained IoT devices that can only perform a single read operation and expect HTTP requests in one packet. The optimization uses zero-copy ``writelines`` when coalescing data and works with both regular and chunked transfer encoding.
4+
5+
When ``aiohttp`` uses client middleware to communicate with an ``aiohttp`` server, connection reuse is more likely to occur since complete responses arrive in a single packet for small payloads.
6+
7+
This aligns ``aiohttp`` with other popular HTTP clients that already coalesce small requests.

aiohttp/abc.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,13 @@ async def write_headers(
232232
) -> None:
233233
"""Write HTTP headers"""
234234

235+
def send_headers(self) -> None:
236+
"""Force sending buffered headers if not already sent.
237+
238+
Required only if write_headers() buffers headers instead of sending immediately.
239+
For backwards compatibility, this method does nothing by default.
240+
"""
241+
235242

236243
class AbstractAccessLogger(ABC):
237244
"""Abstract writer to access log."""

aiohttp/client_reqrep.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,8 @@ async def write_bytes(
709709
"""
710710
# 100 response
711711
if self._continue is not None:
712+
# Force headers to be sent before waiting for 100-continue
713+
writer.send_headers()
712714
await writer.drain()
713715
await self._continue
714716

@@ -826,7 +828,10 @@ async def send(self, conn: "Connection") -> "ClientResponse":
826828

827829
# status + headers
828830
status_line = f"{self.method} {path} HTTP/{v.major}.{v.minor}"
831+
832+
# Buffer headers for potential coalescing with body
829833
await writer.write_headers(status_line, self.headers)
834+
830835
task: Optional["asyncio.Task[None]"]
831836
if self.body or self._continue is not None or protocol.writing_paused:
832837
coro = self.write_bytes(writer, conn, self._get_content_length())

aiohttp/http_writer.py

Lines changed: 144 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import sys
55
from typing import ( # noqa
6+
TYPE_CHECKING,
67
Any,
78
Awaitable,
89
Callable,
@@ -66,6 +67,8 @@ def __init__(
6667
self.loop = loop
6768
self._on_chunk_sent: _T_OnChunkSent = on_chunk_sent
6869
self._on_headers_sent: _T_OnHeadersSent = on_headers_sent
70+
self._headers_buf: Optional[bytes] = None
71+
self._headers_written: bool = False
6972

7073
@property
7174
def transport(self) -> Optional[asyncio.Transport]:
@@ -106,14 +109,58 @@ def _writelines(self, chunks: Iterable[bytes]) -> None:
106109
else:
107110
transport.writelines(chunks)
108111

112+
def _write_chunked_payload(
113+
self, chunk: Union[bytes, bytearray, "memoryview[int]", "memoryview[bytes]"]
114+
) -> None:
115+
"""Write a chunk with proper chunked encoding."""
116+
chunk_len_pre = f"{len(chunk):x}\r\n".encode("ascii")
117+
self._writelines((chunk_len_pre, chunk, b"\r\n"))
118+
119+
def _send_headers_with_payload(
120+
self,
121+
chunk: Union[bytes, bytearray, "memoryview[int]", "memoryview[bytes]"],
122+
is_eof: bool,
123+
) -> None:
124+
"""Send buffered headers with payload, coalescing into single write."""
125+
# Mark headers as written
126+
self._headers_written = True
127+
headers_buf = self._headers_buf
128+
self._headers_buf = None
129+
130+
if TYPE_CHECKING:
131+
# Safe because callers (write() and write_eof()) only invoke this method
132+
# after checking that self._headers_buf is truthy
133+
assert headers_buf is not None
134+
135+
if not self.chunked:
136+
# Non-chunked: coalesce headers with body
137+
if chunk:
138+
self._writelines((headers_buf, chunk))
139+
else:
140+
self._write(headers_buf)
141+
return
142+
143+
# Coalesce headers with chunked data
144+
if chunk:
145+
chunk_len_pre = f"{len(chunk):x}\r\n".encode("ascii")
146+
if is_eof:
147+
self._writelines((headers_buf, chunk_len_pre, chunk, b"\r\n0\r\n\r\n"))
148+
else:
149+
self._writelines((headers_buf, chunk_len_pre, chunk, b"\r\n"))
150+
elif is_eof:
151+
self._writelines((headers_buf, b"0\r\n\r\n"))
152+
else:
153+
self._write(headers_buf)
154+
109155
async def write(
110156
self,
111157
chunk: Union[bytes, bytearray, memoryview],
112158
*,
113159
drain: bool = True,
114160
LIMIT: int = 0x10000,
115161
) -> None:
116-
"""Writes chunk of data to a stream.
162+
"""
163+
Writes chunk of data to a stream.
117164
118165
write_eof() indicates end of stream.
119166
writer can't be used after write_eof() method being called.
@@ -142,31 +189,75 @@ async def write(
142189
if not chunk:
143190
return
144191

192+
# Handle buffered headers for small payload optimization
193+
if self._headers_buf and not self._headers_written:
194+
self._send_headers_with_payload(chunk, False)
195+
if drain and self.buffer_size > LIMIT:
196+
self.buffer_size = 0
197+
await self.drain()
198+
return
199+
145200
if chunk:
146201
if self.chunked:
147-
self._writelines(
148-
(f"{len(chunk):x}\r\n".encode("ascii"), chunk, b"\r\n")
149-
)
202+
self._write_chunked_payload(chunk)
150203
else:
151204
self._write(chunk)
152205

153-
if self.buffer_size > LIMIT and drain:
206+
if drain and self.buffer_size > LIMIT:
154207
self.buffer_size = 0
155208
await self.drain()
156209

157210
async def write_headers(
158211
self, status_line: str, headers: "CIMultiDict[str]"
159212
) -> None:
160-
"""Write request/response status and headers."""
213+
"""Write headers to the stream."""
161214
if self._on_headers_sent is not None:
162215
await self._on_headers_sent(headers)
163-
164216
# status + headers
165217
buf = _serialize_headers(status_line, headers)
166-
self._write(buf)
218+
self._headers_written = False
219+
self._headers_buf = buf
220+
221+
def send_headers(self) -> None:
222+
"""Force sending buffered headers if not already sent."""
223+
if not self._headers_buf or self._headers_written:
224+
return
225+
226+
self._headers_written = True
227+
headers_buf = self._headers_buf
228+
self._headers_buf = None
229+
230+
if TYPE_CHECKING:
231+
# Safe because we only enter this block when self._headers_buf is truthy
232+
assert headers_buf is not None
233+
234+
self._write(headers_buf)
167235

168236
def set_eof(self) -> None:
169237
"""Indicate that the message is complete."""
238+
if self._eof:
239+
return
240+
241+
# If headers haven't been sent yet, send them now
242+
# This handles the case where there's no body at all
243+
if self._headers_buf and not self._headers_written:
244+
self._headers_written = True
245+
headers_buf = self._headers_buf
246+
self._headers_buf = None
247+
248+
if TYPE_CHECKING:
249+
# Safe because we only enter this block when self._headers_buf is truthy
250+
assert headers_buf is not None
251+
252+
# Combine headers and chunked EOF marker in a single write
253+
if self.chunked:
254+
self._writelines((headers_buf, b"0\r\n\r\n"))
255+
else:
256+
self._write(headers_buf)
257+
elif self.chunked and self._headers_written:
258+
# Headers already sent, just send the final chunk marker
259+
self._write(b"0\r\n\r\n")
260+
170261
self._eof = True
171262

172263
async def write_eof(self, chunk: bytes = b"") -> None:
@@ -176,6 +267,7 @@ async def write_eof(self, chunk: bytes = b"") -> None:
176267
if chunk and self._on_chunk_sent is not None:
177268
await self._on_chunk_sent(chunk)
178269

270+
# Handle body/compression
179271
if self._compress:
180272
chunks: List[bytes] = []
181273
chunks_len = 0
@@ -188,23 +280,61 @@ async def write_eof(self, chunk: bytes = b"") -> None:
188280
chunks.append(flush_chunk)
189281
assert chunks_len
190282

283+
# Send buffered headers with compressed data if not yet sent
284+
if self._headers_buf and not self._headers_written:
285+
self._headers_written = True
286+
headers_buf = self._headers_buf
287+
self._headers_buf = None
288+
289+
if self.chunked:
290+
# Coalesce headers with compressed chunked data
291+
chunk_len_pre = f"{chunks_len:x}\r\n".encode("ascii")
292+
self._writelines(
293+
(headers_buf, chunk_len_pre, *chunks, b"\r\n0\r\n\r\n")
294+
)
295+
else:
296+
# Coalesce headers with compressed data
297+
self._writelines((headers_buf, *chunks))
298+
await self.drain()
299+
self._eof = True
300+
return
301+
302+
# Headers already sent, just write compressed data
191303
if self.chunked:
192304
chunk_len_pre = f"{chunks_len:x}\r\n".encode("ascii")
193305
self._writelines((chunk_len_pre, *chunks, b"\r\n0\r\n\r\n"))
194306
elif len(chunks) > 1:
195307
self._writelines(chunks)
196308
else:
197309
self._write(chunks[0])
198-
elif self.chunked:
310+
await self.drain()
311+
self._eof = True
312+
return
313+
314+
# No compression - send buffered headers if not yet sent
315+
if self._headers_buf and not self._headers_written:
316+
# Use helper to send headers with payload
317+
self._send_headers_with_payload(chunk, True)
318+
await self.drain()
319+
self._eof = True
320+
return
321+
322+
# Handle remaining body
323+
if self.chunked:
199324
if chunk:
200-
chunk_len_pre = f"{len(chunk):x}\r\n".encode("ascii")
201-
self._writelines((chunk_len_pre, chunk, b"\r\n0\r\n\r\n"))
325+
# Write final chunk with EOF marker
326+
self._writelines(
327+
(f"{len(chunk):x}\r\n".encode("ascii"), chunk, b"\r\n0\r\n\r\n")
328+
)
202329
else:
203330
self._write(b"0\r\n\r\n")
204-
elif chunk:
205-
self._write(chunk)
331+
await self.drain()
332+
self._eof = True
333+
return
206334

207-
await self.drain()
335+
if chunk:
336+
self._write(chunk)
337+
await self.drain()
208338

209339
self._eof = True
210340

aiohttp/web_response.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ class StreamResponse(BaseClass, HeadersMixin):
8989
_must_be_empty_body: Optional[bool] = None
9090
_body_length = 0
9191
_cookies: Optional[SimpleCookie] = None
92+
_send_headers_immediately = True
9293

9394
def __init__(
9495
self,
@@ -542,6 +543,9 @@ async def _write_headers(self) -> None:
542543
version = request.version
543544
status_line = f"HTTP/{version[0]}.{version[1]} {self._status} {self._reason}"
544545
await writer.write_headers(status_line, self._headers)
546+
# Send headers immediately if not opted into buffering
547+
if self._send_headers_immediately:
548+
writer.send_headers()
545549

546550
async def write(self, data: Union[bytes, bytearray, memoryview]) -> None:
547551
assert isinstance(
@@ -619,6 +623,7 @@ def __bool__(self) -> bool:
619623
class Response(StreamResponse):
620624

621625
_compressed_body: Optional[bytes] = None
626+
_send_headers_immediately = False
622627

623628
def __init__(
624629
self,

docs/spelling_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ initializer
153153
inline
154154
intaking
155155
io
156+
IoT
156157
ip
157158
IP
158159
ipdb

0 commit comments

Comments
 (0)