Skip to content

Commit 6bd31b2

Browse files
authored
perf: Skip request-body compression for small payloads (#988)
Until now the Python client compressed every request body regardless of size. It now skips compression below 1024 B. Closes #934 ## Why 1024 B - **Parity with the JS client.** It uses the same threshold, the same operator (`>=`), and the same byte-based measurement (`MIN_COMPRESS_BYTES` in `apify-client-js/src/utils.ts`), so both clients now put identical bytes on the wire. - **Small bodies grow.** `run.charge` 40 B to 59 B, `kvs.set_record` 27 B to 45 B, single-item `dataset.push_items` 10 B to 30 B. gzip breaks even only at ~88 B of realistic JSON, and high-entropy bodies such as binary key-value store records inflate at any size, by ~23 B for gzip and ~4 B for brotli. - **No packet is saved below ~1 KB**, and removing a packet is the only thing that buys latency. With ~500 B of request headers on a 1400 B MSS: | Raw body | gzipped | Segments raw to gzip | Packet saved? | | --- | --- | --- | --- | | 786 B | 344 B | 1 to 1 | no | | 1071 B | 447 B | 2 to 1 | yes | | 8250 B | 2958 B | 7 to 3 | yes | Compressing everything instead, the reverse direction raised in the issue, doesn't pay off. Over 1200 realistic bodies it inflates 749 of them while total bytes move only from −62.1% to −63.8%, and that gain sits entirely in the 512–1024 B band where no packet is saved anyway. At 1024 B nothing inflates. ## Changes - `MIN_COMPRESSION_SIZE = 1024`. `_prepare_request_call` compresses only at or above it, measured on the encoded bytes so a multibyte `str` is judged correctly. - A caller-supplied `Content-Encoding` is now dropped on a skipped body, where it would otherwise survive and mislabel an uncompressed payload. - The async client skips the `asyncio.to_thread` hop for any body it won't compress, the hop costing 36–68 µs against 5–12 µs of compression. `_is_body_worth_compressing` sits next to the rule it mirrors so the two can't drift apart. A `json=` body still hops, its size being unknown until serialized. - Docs: new "Minimum body size" section. The page claimed the client compresses every request body. ## Verification Against the live API, 8 value shapes round-trip byte-identical with and without compression, small uncompressed bodies are accepted by `dataset.push_items`, `rq.add_request`, `rq.batch_add_requests`, `dataset.update`, `schedules().create`, `schedule.update`, and `webhooks().create`, and latency is unchanged. Tests that compressed tiny bodies were passing vacuously. They now cover the 0/1/1023/1024/1025 boundary for both gzip and brotli, the byte-vs-character threshold, the dropped header, and the thread-hop decisions. `test_run_charge`'s `compression` axis was the suite's only end-to-end compression coverage and had gone vacuous on its 39-byte body, so it's replaced by a test asserting an above-threshold body reaches the server compressed under the configured algorithm. *✍️ Drafted by Claude Code*
1 parent f657f76 commit 6bd31b2

9 files changed

Lines changed: 347 additions & 147 deletions

File tree

docs/02_concepts/13_http_compression.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ The Apify client compresses request bodies before sending them to the API. It re
1515

1616
## How it works
1717

18-
The client compresses request bodies using the compressor configured via the `compression` parameter (default `'gzip'`). The server supports both gzip and brotli and decompresses the request body transparently.
18+
The client compresses request bodies using the compressor configured via the `compression` parameter (default `'gzip'`). The server supports both gzip and brotli and decompresses the request body transparently. A body is compressed only when it is large enough to benefit and its content type isn't already compressed, as the next two sections describe.
19+
20+
## Minimum body size
21+
22+
The client sends bodies smaller than 1024 bytes without compression and without the `Content-Encoding` header. A body of this size fits in one network packet, so compression doesn't remove a network round trip and only costs CPU time. For very small bodies, the compression format adds bytes and can make the body larger.
1923

2024
## Already-compressed payloads
2125

@@ -84,7 +88,7 @@ client = ApifyClient(token='MY-APIFY-TOKEN', compression=BrotliHttpCompressor(qu
8488
client = ApifyClient(token='MY-APIFY-TOKEN', compression=GzipHttpCompressor(quality=9))
8589
```
8690

87-
You can also implement a fully custom compressor by subclassing `HttpCompressor`:
91+
You can also implement a fully custom compressor by subclassing `HttpCompressor`. The client calls it only for bodies that reach the [minimum body size](#minimum-body-size) and aren't [already compressed](#already-compressed-payloads):
8892

8993
```python
9094
from apify_client import ApifyClient

src/apify_client/_consts.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@
3535
OVERRIDABLE_DEFAULT_HEADERS = {'Accept', 'Authorization', 'Accept-Encoding', 'User-Agent'}
3636
"""Headers that can be overridden by users, but will trigger a warning if they do so, as it may lead to API errors."""
3737

38+
MIN_COMPRESSION_SIZE = 1024
39+
"""Smallest request body, in bytes, that is worth compressing.
40+
41+
A smaller body already fits in a single network packet, so compressing it costs CPU time without
42+
saving a round trip.
43+
"""
44+
3845
ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES = ('audio/', 'image/', 'video/')
3946
"""Media type prefixes whose payloads carry their own compression, so compressing the request body is wasted work."""
4047

src/apify_client/http_clients/_base.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
DEFAULT_TIMEOUT_MAX,
1717
DEFAULT_TIMEOUT_MEDIUM,
1818
DEFAULT_TIMEOUT_SHORT,
19+
MIN_COMPRESSION_SIZE,
1920
)
2021
from apify_client._docs import docs_group
2122
from apify_client._statistics import ClientStatistics
@@ -223,6 +224,24 @@ def _compute_timeout(self, timeout: Timeout, *, attempt: int) -> int | float | N
223224
new_timeout = min(resolved * (2 ** (attempt - 1)), self._timeout_max)
224225
return to_seconds(new_timeout)
225226

227+
@staticmethod
228+
def _is_body_worth_compressing(data: str | bytes | bytearray | None) -> bool:
229+
"""Whether this body clears the size threshold `_prepare_request_call` compresses at, cheaply.
230+
231+
Below the threshold nothing is ever compressed. At or above it the content type still decides, but
232+
checking that here would buy nothing - a body that turns out to be already compressed only wastes the
233+
thread hop this answer guards.
234+
235+
The threshold is measured on encoded bytes, so a character count alone cannot decide a `str`. It is a
236+
lower bound, so a `str` long enough in characters is long enough in bytes too. Below that the encoded
237+
length decides, and the body is then under 4 KiB, so encoding it here is cheap.
238+
"""
239+
if isinstance(data, str):
240+
return len(data) >= MIN_COMPRESSION_SIZE or len(data.encode('utf-8')) >= MIN_COMPRESSION_SIZE
241+
if isinstance(data, (bytes, bytearray)):
242+
return len(data) >= MIN_COMPRESSION_SIZE
243+
return False
244+
226245
def _prepare_request_call(
227246
self,
228247
*,
@@ -234,10 +253,11 @@ def _prepare_request_call(
234253
"""Prepare headers, params, and body for an HTTP request.
235254
236255
Merges the client's default headers (including authorization) with per-request headers, serializes JSON
237-
and compresses the body unless its content type says the payload is already compressed. Header names are
238-
treated case-insensitively and per-request values win over the client defaults. For JSON bodies, a
239-
`Content-Type` header is set unless the caller supplied one. `Content-Encoding` always describes what was
240-
actually applied to the body, so a caller-supplied value is dropped whenever nothing was compressed.
256+
and compresses the body unless it is smaller than `MIN_COMPRESSION_SIZE` or its content type says the
257+
payload is already compressed. Header names are treated case-insensitively and per-request values win
258+
over the client defaults. For JSON bodies, a `Content-Type` header is set unless the caller supplied one.
259+
`Content-Encoding` always describes what was actually applied to the body, so a caller-supplied value is
260+
dropped whenever nothing was compressed.
241261
"""
242262
if json is not None and data is not None:
243263
raise ValueError('Cannot pass both "json" and "data" parameters at the same time!')
@@ -259,7 +279,7 @@ def _prepare_request_call(
259279
data = bytes(data)
260280

261281
content_type = next((value for key, value in headers.items() if key.lower() == 'content-type'), None)
262-
if is_compressible_content_type(content_type):
282+
if len(data) >= MIN_COMPRESSION_SIZE and is_compressible_content_type(content_type):
263283
data = self._http_compressor.compress(data)
264284
headers = self._merge_headers(headers, {'Content-Encoding': self._http_compressor.content_encoding})
265285
compressed = True

src/apify_client/http_clients/_impit.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -395,9 +395,10 @@ async def call(
395395
self._statistics.calls += 1
396396

397397
# Serializing and compressing a request body is CPU-bound and would block the event loop, so
398-
# offload request preparation to a worker thread whenever there is a body. Bodyless requests
399-
# skip the thread hop, as they have no expensive work to move off the loop.
400-
if json is not None or data is not None:
398+
# offload preparation to a worker thread whenever there is something to compress. A body the
399+
# client sends as it is costs less to prepare inline than the hop itself. A `json` body always
400+
# hops, as its size is only known once serialized.
401+
if json is not None or self._is_body_worth_compressing(data):
401402
prepared_headers, prepared_params, content = await asyncio.to_thread(
402403
self._prepare_request_call,
403404
headers=headers,

tests/unit/test_client_request_queue.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,15 @@ def _make_large_requests() -> list[RequestDraftDict]:
120120

121121

122122
def _payload_capturing_handler(payloads: list[bytes]) -> Callable[[Request], Response]:
123-
"""Return a handler that records each POST body (gzip-decompressed) and responds with an empty batch result."""
123+
"""Return a handler that records each POST body and responds with an empty batch result.
124+
125+
Bodies below the client's compression threshold arrive uncompressed, so the recorded payload is
126+
decompressed only when the request says it was encoded.
127+
"""
124128

125129
def handler(request: Request) -> Response:
126-
payloads.append(gzip.decompress(request.get_data()))
130+
body = request.get_data()
131+
payloads.append(gzip.decompress(body) if request.headers.get('Content-Encoding') == 'gzip' else body)
127132
return Response(_EMPTY_BATCH_RESPONSE_CONTENT, status=200, content_type='application/json')
128133

129134
return handler

0 commit comments

Comments
 (0)