Skip to content
Merged
37 changes: 35 additions & 2 deletions docs/02_concepts/13_http_compression.mdx
Original file line number Diff line number Diff line change
@@ -1,15 +1,48 @@
---
id: http-compression
title: HTTP compression
description: The client compresses every request body automatically using gzip by default, with optional brotli via an explicit opt-in.
description: The client compresses request bodies automatically using gzip by default, with optional brotli via an explicit opt-in.
---

The Apify client compresses every request body before sending it to the API. It reduces the amount of data transferred over the network, resulting in faster requests and lower bandwidth usage, especially for large payloads such as Actor inputs, dataset uploads, or key-value store records.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeBlock from '@theme/CodeBlock';

import SkipCompressionAsyncExample from '!!raw-loader!./code/13_skip_compression_async.py';
import SkipCompressionSyncExample from '!!raw-loader!./code/13_skip_compression_sync.py';

The Apify client compresses request bodies before sending them to the API. It reduces the amount of data transferred over the network, resulting in faster requests and lower bandwidth usage, especially for large payloads such as Actor inputs, dataset uploads, or key-value store records.

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

## Already-compressed payloads

Some payloads carry their own compression, so compressing them again costs CPU and memory while making the request slightly larger. The client skips compression when the request's `Content-Type` is one of these:

- any `image/*`, `audio/*`, or `video/*` type
- archives such as `application/zip`, `application/gzip`, or `application/x-7z-compressed`
- office documents and packages built on ZIP, such as `.docx`, `.xlsx`, `.epub`, or `.apk`
- web fonts (`font/woff`, `font/woff2`)

Two kinds of media type are compressed anyway: raw formats such as `image/bmp`, `image/tiff`, and `audio/wav`, and subtypes with a structured syntax suffix such as `image/svg+xml`. Set an accurate `content_type` when uploading media to a key-value store:

<Tabs>
<TabItem value="AsyncExample" label="Async client" default>
<CodeBlock className="language-python">
{SkipCompressionAsyncExample}
</CodeBlock>
</TabItem>
<TabItem value="SyncExample" label="Sync client">
<CodeBlock className="language-python">
{SkipCompressionSyncExample}
</CodeBlock>
</TabItem>
</Tabs>

Without an explicit content type, a `bytes` value is sent as `application/octet-stream`, which the client can't tell apart from uncompressed binary data and therefore still compresses. File-like values are streamed rather than buffered, so they're never compressed regardless of their content type.

## Configuration

To choose the compression algorithm, pass `compression` to the client constructor:
Expand Down
20 changes: 20 additions & 0 deletions docs/02_concepts/code/13_skip_compression_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import asyncio
from pathlib import Path

from apify_client import ApifyClientAsync

TOKEN = 'MY-APIFY-TOKEN'


async def main() -> None:
apify_client = ApifyClientAsync(TOKEN)
kvs_client = apify_client.key_value_store('MY-KVS-ID')

screenshot = await asyncio.to_thread(Path('screenshot.png').read_bytes)

# The explicit content type lets the client skip compressing the PNG.
await kvs_client.set_record('screenshot', screenshot, content_type='image/png')


if __name__ == '__main__':
asyncio.run(main())
15 changes: 15 additions & 0 deletions docs/02_concepts/code/13_skip_compression_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from pathlib import Path

from apify_client import ApifyClient

TOKEN = 'MY-APIFY-TOKEN'


def main() -> None:
apify_client = ApifyClient(TOKEN)
kvs_client = apify_client.key_value_store('MY-KVS-ID')

screenshot = Path('screenshot.png').read_bytes()

# The explicit content type lets the client skip compressing the PNG.
kvs_client.set_record('screenshot', screenshot, content_type='image/png')
53 changes: 53 additions & 0 deletions src/apify_client/_consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,56 @@

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

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

ALREADY_COMPRESSED_MEDIA_TYPES = frozenset(
{
'application/epub+zip',
'application/gzip',
'application/java-archive',
'application/vnd.android.package-archive',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.rar',
'application/x-7z-compressed',
'application/x-bzip',
'application/x-bzip2',
'application/x-gzip',
'application/x-rar-compressed',
'application/x-xz',
'application/x-zip-compressed',
'application/zip',
'application/zstd',
'font/woff',
'font/woff2',
}
)
"""Exact media types whose payloads carry their own compression."""

COMPRESSIBLE_MEDIA_TYPES = frozenset(
Comment thread
vdusek marked this conversation as resolved.
{
'audio/aiff',
'audio/basic',
'audio/l16',
'audio/l24',
'audio/midi',
'audio/vnd.wave',
'audio/wav',
'audio/wave',
'audio/x-aiff',
'audio/x-wav',
'image/bmp',
'image/tiff',
'image/vnd.adobe.photoshop',
'image/vnd.microsoft.icon',
'image/x-icon',
'image/x-ms-bmp',
}
)
"""Uncompressed media types that sit under an already-compressed prefix, so compressing them still pays off."""

COMPRESSIBLE_MEDIA_TYPE_SUFFIXES = ('+json', '+xml')
"""Structured syntax suffixes marking a media type as text even under an already-compressed prefix (`image/svg+xml`)."""
36 changes: 35 additions & 1 deletion src/apify_client/_utils/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import warnings
from typing import TYPE_CHECKING

from apify_client._consts import OVERRIDABLE_DEFAULT_HEADERS
from apify_client._consts import (
ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES,
ALREADY_COMPRESSED_MEDIA_TYPES,
COMPRESSIBLE_MEDIA_TYPE_SUFFIXES,
COMPRESSIBLE_MEDIA_TYPES,
OVERRIDABLE_DEFAULT_HEADERS,
)

if TYPE_CHECKING:
from apify_client.http_clients import HttpResponse
Expand All @@ -21,6 +27,34 @@ def to_safe_id(id: str) -> str:
return id.replace('/', '~')


def is_compressible_content_type(content_type: str | None) -> bool:
"""Decide whether a request body with the given content type is worth compressing.
Images, audio, video and archives already carry their own compression. Running them through gzip or brotli
burns CPU, holds a second full copy of the body in memory, and usually produces output slightly larger than
the input. Formats that are raw despite such a media type, for example `image/bmp` or `audio/wav`, are still
compressed. A body with no content type is assumed to be compressible.
Args:
content_type: The value of the `Content-Type` header, if any.
Returns:
`True` if the body should be compressed before it is sent.
"""
if not content_type:
return True

# `Content-Type` is case-insensitive and may carry parameters, for example `text/plain; charset=utf-8`.
media_type = content_type.split(';', 1)[0].strip().lower()

if media_type in COMPRESSIBLE_MEDIA_TYPES or media_type.endswith(COMPRESSIBLE_MEDIA_TYPE_SUFFIXES):
return True

return not (
media_type in ALREADY_COMPRESSED_MEDIA_TYPES or media_type.startswith(ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES)
)


def response_to_dict(response: HttpResponse) -> dict:
"""Parse the API response as a dictionary and validate its type.
Expand Down
27 changes: 20 additions & 7 deletions src/apify_client/http_clients/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
)
from apify_client._docs import docs_group
from apify_client._statistics import ClientStatistics
from apify_client._utils.http import is_compressible_content_type
from apify_client._utils.time import to_seconds
from apify_client.http_compressors._gzip import GzipHttpCompressor

Expand Down Expand Up @@ -232,29 +233,41 @@ def _prepare_request_call(
) -> tuple[dict[str, str], dict[str, Any] | None, bytes | None]:
"""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. 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.
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.
"""
if json is not None and data is not None:
raise ValueError('Cannot pass both "json" and "data" parameters at the same time!')

headers = self._merge_headers(self._headers, headers)

# Dump JSON data to string so it can be compressed.
# Dump JSON data to a string so it can be sent as a request body.
if json is not None:
data = jsonlib.dumps(json, ensure_ascii=False, allow_nan=False, default=str).encode('utf-8')
if not any(key.lower() == 'content-type' for key in headers):
headers['Content-Type'] = 'application/json'

compressed = False

if isinstance(data, (str, bytes, bytearray)):
if isinstance(data, str):
data = data.encode('utf-8')
elif isinstance(data, bytearray):
data = bytes(data)
data = self._http_compressor.compress(data)
headers = self._merge_headers(headers, {'Content-Encoding': self._http_compressor.content_encoding})

content_type = next((value for key, value in headers.items() if key.lower() == 'content-type'), None)
if 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

# Anything left uncompressed goes out as-is - a file-like body included - so a caller-supplied encoding
# would misdescribe it.
if data is not None and not compressed:
headers = {key: value for key, value in headers.items() if key.lower() != 'content-encoding'}
Comment thread
vdusek marked this conversation as resolved.

return (headers, self._parse_params(params), data)

Expand Down
4 changes: 2 additions & 2 deletions src/apify_client/http_clients/_impit.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,8 +395,8 @@ 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 to compress. Bodyless
# requests skip the thread hop, as they have no expensive work to move off the loop.
# 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:
prepared_headers, prepared_params, content = await asyncio.to_thread(
self._prepare_request_call,
Expand Down
82 changes: 80 additions & 2 deletions tests/unit/test_http_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import asyncio
import gzip
import io
import threading
import time
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, Mock

import brotli
Expand Down Expand Up @@ -461,6 +462,83 @@ def test_prepare_request_call_with_bytearray_data(compressor_case: tuple) -> Non
assert decompress(data) == b'test bytearray'


@pytest.mark.parametrize(
'content_type',
[
pytest.param('image/png', id='image'),
pytest.param('video/mp4', id='video'),
pytest.param('application/zip', id='archive'),
],
)
def test_prepare_request_call_skips_compression_for_already_compressed_content(content_type: str) -> None:
"""An already-compressed body is sent verbatim, carries no `Content-Encoding`, and keeps every other header."""
client = _ConcreteHttpClient(token='test_token', http_compressor=GzipHttpCompressor())

headers, _params, data = client._prepare_request_call(
headers={'content-type': content_type},
data=b'\x89PNG binary',
)

assert data == b'\x89PNG binary'
assert not any(key.lower() == 'content-encoding' for key in headers)
assert headers['Authorization'] == 'Bearer test_token'
assert headers['content-type'] == content_type
assert headers['User-Agent'] == client._headers['User-Agent']


def test_prepare_request_call_drops_caller_content_encoding_when_compression_is_skipped() -> None:
"""Skipping compression also strips a caller-supplied `Content-Encoding`, which would misdescribe the body."""
client = _ConcreteHttpClient(http_compressor=GzipHttpCompressor())

headers, _params, data = client._prepare_request_call(
headers={'content-type': 'image/jpeg', 'content-encoding': 'br'},
data=b'jpeg binary',
)

assert data == b'jpeg binary'
assert not any(key.lower() == 'content-encoding' for key in headers)


def test_prepare_request_call_drops_caller_content_encoding_for_a_streamed_body() -> None:
"""A body that is streamed rather than compressed, such as a file-like object, also loses `Content-Encoding`."""
client = _ConcreteHttpClient(http_compressor=GzipHttpCompressor())
stream = io.BytesIO(b'raw payload')

headers, _params, data = client._prepare_request_call(
headers={'content-encoding': 'gzip'},
data=cast('bytes', stream),
)

assert data is stream
assert not any(key.lower() == 'content-encoding' for key in headers)


@pytest.mark.parametrize(
'content_type',
[
pytest.param('image/svg+xml', id='structured xml suffix'),
pytest.param('image/bmp', id='raw bitmap'),
pytest.param('audio/wav', id='raw audio'),
],
)
def test_prepare_request_call_compresses_exceptions_to_compressed_prefixes(
content_type: str,
compressor_case: tuple,
) -> None:
"""Types that are text or raw are compressed even when they sit under an already-compressed prefix."""
compressor, content_encoding, decompress = compressor_case
client = _ConcreteHttpClient(http_compressor=compressor)

headers, _params, data = client._prepare_request_call(
headers={'content-type': content_type},
data=b'raw payload',
)

assert headers['Content-Encoding'] == content_encoding
assert isinstance(data, bytes)
assert decompress(data) == b'raw payload'


def test_prepare_request_call_json_and_data_error() -> None:
"""Test _prepare_request_call raises error when both json and data are provided."""
client = _ConcreteHttpClient()
Expand Down Expand Up @@ -520,7 +598,7 @@ def test_prepare_request_call_json_keeps_caller_content_type() -> None:


def test_prepare_request_call_replaces_caller_content_encoding() -> None:
"""The Content-Encoding header always reflects the compressor actually applied, replacing any caller value."""
"""A compressed body reports the compressor actually applied, replacing any caller-supplied Content-Encoding."""
client = _ConcreteHttpClient(http_compressor=GzipHttpCompressor())

headers, _params, _data = client._prepare_request_call(headers={'content-encoding': 'br'}, data='payload')
Expand Down
Loading
Loading