Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/02_concepts/13_http_compression.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ The Apify client compresses request bodies before sending them to the API. It re

## How it works

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.
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.

## Minimum body size

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.

## Already-compressed payloads

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

You can also implement a fully custom compressor by subclassing `HttpCompressor`:
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):

```python
from apify_client import ApifyClient
Expand Down
7 changes: 7 additions & 0 deletions src/apify_client/_consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@
OVERRIDABLE_DEFAULT_HEADERS = {'Accept', 'Authorization', 'Accept-Encoding', 'User-Agent'}
"""Headers that can be overridden by users, but will trigger a warning if they do so, as it may lead to API errors."""

MIN_COMPRESSION_SIZE = 1024
"""Smallest request body, in bytes, that is worth compressing.

A smaller body already fits in a single network packet, so compressing it costs CPU time without
saving a round trip.
"""

ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES = ('audio/', 'image/', 'video/')
"""Media type prefixes whose payloads carry their own compression, so compressing the request body is wasted work."""

Expand Down
30 changes: 25 additions & 5 deletions src/apify_client/http_clients/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
DEFAULT_TIMEOUT_MAX,
DEFAULT_TIMEOUT_MEDIUM,
DEFAULT_TIMEOUT_SHORT,
MIN_COMPRESSION_SIZE,
)
from apify_client._docs import docs_group
from apify_client._statistics import ClientStatistics
Expand Down Expand Up @@ -223,6 +224,24 @@ def _compute_timeout(self, timeout: Timeout, *, attempt: int) -> int | float | N
new_timeout = min(resolved * (2 ** (attempt - 1)), self._timeout_max)
return to_seconds(new_timeout)

@staticmethod
def _is_body_worth_compressing(data: str | bytes | bytearray | None) -> bool:
"""Whether this body clears the size threshold `_prepare_request_call` compresses at, cheaply.

Below the threshold nothing is ever compressed. At or above it the content type still decides, but
checking that here would buy nothing - a body that turns out to be already compressed only wastes the
thread hop this answer guards.

The threshold is measured on encoded bytes, so a character count alone cannot decide a `str`. It is a
lower bound, so a `str` long enough in characters is long enough in bytes too. Below that the encoded
length decides, and the body is then under 4 KiB, so encoding it here is cheap.
"""
if isinstance(data, str):
return len(data) >= MIN_COMPRESSION_SIZE or len(data.encode('utf-8')) >= MIN_COMPRESSION_SIZE
if isinstance(data, (bytes, bytearray)):
return len(data) >= MIN_COMPRESSION_SIZE
return False

def _prepare_request_call(
self,
*,
Expand All @@ -234,10 +253,11 @@ def _prepare_request_call(
"""Prepare headers, params, and body for an HTTP request.

Merges the client's default headers (including authorization) with per-request headers, serializes JSON
and compresses the body unless its content type says the payload is already compressed. Header names are
treated case-insensitively and per-request values win over the client defaults. For JSON bodies, a
`Content-Type` header is set unless the caller supplied one. `Content-Encoding` always describes what was
actually applied to the body, so a caller-supplied value is dropped whenever nothing was compressed.
and compresses the body unless it is smaller than `MIN_COMPRESSION_SIZE` or its content type says the
payload is already compressed. Header names are treated case-insensitively and per-request values win
over the client defaults. For JSON bodies, a `Content-Type` header is set unless the caller supplied one.
`Content-Encoding` always describes what was actually applied to the body, so a caller-supplied value is
dropped whenever nothing was compressed.
"""
if json is not None and data is not None:
raise ValueError('Cannot pass both "json" and "data" parameters at the same time!')
Expand All @@ -259,7 +279,7 @@ def _prepare_request_call(
data = bytes(data)

content_type = next((value for key, value in headers.items() if key.lower() == 'content-type'), None)
if is_compressible_content_type(content_type):
if len(data) >= MIN_COMPRESSION_SIZE and is_compressible_content_type(content_type):
data = self._http_compressor.compress(data)
headers = self._merge_headers(headers, {'Content-Encoding': self._http_compressor.content_encoding})
compressed = True
Expand Down
7 changes: 4 additions & 3 deletions src/apify_client/http_clients/_impit.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,9 +395,10 @@ async def call(
self._statistics.calls += 1

# Serializing and compressing a request body is CPU-bound and would block the event loop, so
# offload request preparation to a worker thread whenever there is a body. Bodyless requests
# skip the thread hop, as they have no expensive work to move off the loop.
if json is not None or data is not None:
# offload preparation to a worker thread whenever there is something to compress. A body the
# client sends as it is costs less to prepare inline than the hop itself. A `json` body always
# hops, as its size is only known once serialized.
if json is not None or self._is_body_worth_compressing(data):
prepared_headers, prepared_params, content = await asyncio.to_thread(
self._prepare_request_call,
headers=headers,
Expand Down
9 changes: 7 additions & 2 deletions tests/unit/test_client_request_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,15 @@ def _make_large_requests() -> list[RequestDraftDict]:


def _payload_capturing_handler(payloads: list[bytes]) -> Callable[[Request], Response]:
"""Return a handler that records each POST body (gzip-decompressed) and responds with an empty batch result."""
"""Return a handler that records each POST body and responds with an empty batch result.

Bodies below the client's compression threshold arrive uncompressed, so the recorded payload is
decompressed only when the request says it was encoded.
"""

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

return handler
Expand Down
Loading
Loading