Skip to content

Commit 452458a

Browse files
authored
Optimize small HTTP requests/responses by coalescing headers and body into a single packet (#10991)
1 parent 54f1a84 commit 452458a

11 files changed

+1095
-26
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
@@ -230,6 +230,13 @@ async def write_headers(
230230
) -> None:
231231
"""Write HTTP headers"""
232232

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

234241
class AbstractAccessLogger(ABC):
235242
"""Abstract writer to access log."""

aiohttp/client_reqrep.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,8 @@ async def write_bytes(
646646
"""
647647
# 100 response
648648
if self._continue is not None:
649+
# Force headers to be sent before waiting for 100-continue
650+
writer.send_headers()
649651
await writer.drain()
650652
await self._continue
651653

@@ -763,7 +765,10 @@ async def send(self, conn: "Connection") -> "ClientResponse":
763765

764766
# status + headers
765767
status_line = f"{self.method} {path} HTTP/{v.major}.{v.minor}"
768+
769+
# Buffer headers for potential coalescing with body
766770
await writer.write_headers(status_line, self.headers)
771+
767772
task: Optional["asyncio.Task[None]"]
768773
if self.body or self._continue is not None or protocol.writing_paused:
769774
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,
@@ -71,6 +72,8 @@ def __init__(
7172
self.loop = loop
7273
self._on_chunk_sent: _T_OnChunkSent = on_chunk_sent
7374
self._on_headers_sent: _T_OnHeadersSent = on_headers_sent
75+
self._headers_buf: Optional[bytes] = None
76+
self._headers_written: bool = False
7477

7578
@property
7679
def transport(self) -> Optional[asyncio.Transport]:
@@ -118,14 +121,58 @@ def _writelines(
118121
else:
119122
transport.writelines(chunks) # type: ignore[arg-type]
120123

124+
def _write_chunked_payload(
125+
self, chunk: Union[bytes, bytearray, "memoryview[int]", "memoryview[bytes]"]
126+
) -> None:
127+
"""Write a chunk with proper chunked encoding."""
128+
chunk_len_pre = f"{len(chunk):x}\r\n".encode("ascii")
129+
self._writelines((chunk_len_pre, chunk, b"\r\n"))
130+
131+
def _send_headers_with_payload(
132+
self,
133+
chunk: Union[bytes, bytearray, "memoryview[int]", "memoryview[bytes]"],
134+
is_eof: bool,
135+
) -> None:
136+
"""Send buffered headers with payload, coalescing into single write."""
137+
# Mark headers as written
138+
self._headers_written = True
139+
headers_buf = self._headers_buf
140+
self._headers_buf = None
141+
142+
if TYPE_CHECKING:
143+
# Safe because callers (write() and write_eof()) only invoke this method
144+
# after checking that self._headers_buf is truthy
145+
assert headers_buf is not None
146+
147+
if not self.chunked:
148+
# Non-chunked: coalesce headers with body
149+
if chunk:
150+
self._writelines((headers_buf, chunk))
151+
else:
152+
self._write(headers_buf)
153+
return
154+
155+
# Coalesce headers with chunked data
156+
if chunk:
157+
chunk_len_pre = f"{len(chunk):x}\r\n".encode("ascii")
158+
if is_eof:
159+
self._writelines((headers_buf, chunk_len_pre, chunk, b"\r\n0\r\n\r\n"))
160+
else:
161+
self._writelines((headers_buf, chunk_len_pre, chunk, b"\r\n"))
162+
elif is_eof:
163+
self._writelines((headers_buf, b"0\r\n\r\n"))
164+
else:
165+
self._write(headers_buf)
166+
121167
async def write(
122168
self,
123169
chunk: Union[bytes, bytearray, "memoryview[int]", "memoryview[bytes]"],
124170
*,
125171
drain: bool = True,
126172
LIMIT: int = 0x10000,
127173
) -> None:
128-
"""Writes chunk of data to a stream.
174+
"""
175+
Writes chunk of data to a stream.
129176
130177
write_eof() indicates end of stream.
131178
writer can't be used after write_eof() method being called.
@@ -154,31 +201,75 @@ async def write(
154201
if not chunk:
155202
return
156203

204+
# Handle buffered headers for small payload optimization
205+
if self._headers_buf and not self._headers_written:
206+
self._send_headers_with_payload(chunk, False)
207+
if drain and self.buffer_size > LIMIT:
208+
self.buffer_size = 0
209+
await self.drain()
210+
return
211+
157212
if chunk:
158213
if self.chunked:
159-
self._writelines(
160-
(f"{len(chunk):x}\r\n".encode("ascii"), chunk, b"\r\n")
161-
)
214+
self._write_chunked_payload(chunk)
162215
else:
163216
self._write(chunk)
164217

165-
if self.buffer_size > LIMIT and drain:
218+
if drain and self.buffer_size > LIMIT:
166219
self.buffer_size = 0
167220
await self.drain()
168221

169222
async def write_headers(
170223
self, status_line: str, headers: "CIMultiDict[str]"
171224
) -> None:
172-
"""Write request/response status and headers."""
225+
"""Write headers to the stream."""
173226
if self._on_headers_sent is not None:
174227
await self._on_headers_sent(headers)
175-
176228
# status + headers
177229
buf = _serialize_headers(status_line, headers)
178-
self._write(buf)
230+
self._headers_written = False
231+
self._headers_buf = buf
232+
233+
def send_headers(self) -> None:
234+
"""Force sending buffered headers if not already sent."""
235+
if not self._headers_buf or self._headers_written:
236+
return
237+
238+
self._headers_written = True
239+
headers_buf = self._headers_buf
240+
self._headers_buf = None
241+
242+
if TYPE_CHECKING:
243+
# Safe because we only enter this block when self._headers_buf is truthy
244+
assert headers_buf is not None
245+
246+
self._write(headers_buf)
179247

180248
def set_eof(self) -> None:
181249
"""Indicate that the message is complete."""
250+
if self._eof:
251+
return
252+
253+
# If headers haven't been sent yet, send them now
254+
# This handles the case where there's no body at all
255+
if self._headers_buf and not self._headers_written:
256+
self._headers_written = True
257+
headers_buf = self._headers_buf
258+
self._headers_buf = None
259+
260+
if TYPE_CHECKING:
261+
# Safe because we only enter this block when self._headers_buf is truthy
262+
assert headers_buf is not None
263+
264+
# Combine headers and chunked EOF marker in a single write
265+
if self.chunked:
266+
self._writelines((headers_buf, b"0\r\n\r\n"))
267+
else:
268+
self._write(headers_buf)
269+
elif self.chunked and self._headers_written:
270+
# Headers already sent, just send the final chunk marker
271+
self._write(b"0\r\n\r\n")
272+
182273
self._eof = True
183274

184275
async def write_eof(self, chunk: bytes = b"") -> None:
@@ -188,6 +279,7 @@ async def write_eof(self, chunk: bytes = b"") -> None:
188279
if chunk and self._on_chunk_sent is not None:
189280
await self._on_chunk_sent(chunk)
190281

282+
# Handle body/compression
191283
if self._compress:
192284
chunks: List[bytes] = []
193285
chunks_len = 0
@@ -200,23 +292,61 @@ async def write_eof(self, chunk: bytes = b"") -> None:
200292
chunks.append(flush_chunk)
201293
assert chunks_len
202294

295+
# Send buffered headers with compressed data if not yet sent
296+
if self._headers_buf and not self._headers_written:
297+
self._headers_written = True
298+
headers_buf = self._headers_buf
299+
self._headers_buf = None
300+
301+
if self.chunked:
302+
# Coalesce headers with compressed chunked data
303+
chunk_len_pre = f"{chunks_len:x}\r\n".encode("ascii")
304+
self._writelines(
305+
(headers_buf, chunk_len_pre, *chunks, b"\r\n0\r\n\r\n")
306+
)
307+
else:
308+
# Coalesce headers with compressed data
309+
self._writelines((headers_buf, *chunks))
310+
await self.drain()
311+
self._eof = True
312+
return
313+
314+
# Headers already sent, just write compressed data
203315
if self.chunked:
204316
chunk_len_pre = f"{chunks_len:x}\r\n".encode("ascii")
205317
self._writelines((chunk_len_pre, *chunks, b"\r\n0\r\n\r\n"))
206318
elif len(chunks) > 1:
207319
self._writelines(chunks)
208320
else:
209321
self._write(chunks[0])
210-
elif self.chunked:
322+
await self.drain()
323+
self._eof = True
324+
return
325+
326+
# No compression - send buffered headers if not yet sent
327+
if self._headers_buf and not self._headers_written:
328+
# Use helper to send headers with payload
329+
self._send_headers_with_payload(chunk, True)
330+
await self.drain()
331+
self._eof = True
332+
return
333+
334+
# Handle remaining body
335+
if self.chunked:
211336
if chunk:
212-
chunk_len_pre = f"{len(chunk):x}\r\n".encode("ascii")
213-
self._writelines((chunk_len_pre, chunk, b"\r\n0\r\n\r\n"))
337+
# Write final chunk with EOF marker
338+
self._writelines(
339+
(f"{len(chunk):x}\r\n".encode("ascii"), chunk, b"\r\n0\r\n\r\n")
340+
)
214341
else:
215342
self._write(b"0\r\n\r\n")
216-
elif chunk:
217-
self._write(chunk)
343+
await self.drain()
344+
self._eof = True
345+
return
218346

219-
await self.drain()
347+
if chunk:
348+
self._write(chunk)
349+
await self.drain()
220350

221351
self._eof = True
222352

aiohttp/web_response.py

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

9394
def __init__(
9495
self,
@@ -441,6 +442,10 @@ async def _write_headers(self) -> None:
441442
status_line = f"HTTP/{version[0]}.{version[1]} {self._status} {self._reason}"
442443
await writer.write_headers(status_line, self._headers)
443444

445+
# Send headers immediately if not opted into buffering
446+
if self._send_headers_immediately:
447+
writer.send_headers()
448+
444449
async def write(
445450
self, data: Union[bytes, bytearray, "memoryview[int]", "memoryview[bytes]"]
446451
) -> None:
@@ -519,6 +524,7 @@ def __bool__(self) -> bool:
519524
class Response(StreamResponse):
520525

521526
_compressed_body: Optional[bytes] = None
527+
_send_headers_immediately = False
522528

523529
def __init__(
524530
self,

docs/spelling_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ initializer
154154
inline
155155
intaking
156156
io
157+
IoT
157158
ip
158159
IP
159160
ipdb

0 commit comments

Comments
 (0)