Skip to content
Open
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@

or any other Python package manager that consumes PyPI.

To enable brotli request-body compression (better than gzip, especially for large payloads), install the optional extra:
Comment thread
vdusek marked this conversation as resolved.

```bash
pip install "apify-client[brotli]"
# or
uv add "apify-client[brotli]"
```

Without this extra the client falls back to gzip automatically — no code changes needed.

- From [conda-forge](https://anaconda.org/conda-forge/apify-client), it can be installed with [conda](https://docs.conda.io/en/latest/):

Expand Down
17 changes: 17 additions & 0 deletions docs/01_introduction/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ The Apify client is available as the `apify-client` package [on PyPI](https://py
</TabItem>
</Tabs>

To enable brotli request-body compression (better than gzip, especially for large payloads), install the optional extra:

<Tabs>
<TabItem value="PyPI" label="PyPI" default>
```bash
pip install "apify-client[brotli]"
```
</TabItem>
<TabItem value="conda-forge" label="conda-forge">
```bash
conda install conda-forge::apify-client conda-forge::brotli
```
</TabItem>
</Tabs>

Without this extra the client falls back to gzip automatically — no code changes needed. See [Request body compression](../02_concepts/13_compression.mdx) for a full comparison of the two algorithms.

## Quick example

The following example shows how to run an Actor and retrieve its results:
Expand Down
42 changes: 42 additions & 0 deletions docs/02_concepts/13_compression.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
id: compression
title: Request body compression
description: The client compresses every request body automatically, using brotli when available or gzip as a fallback.
---

The Apify client compresses every request body before sending it to the API. This 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 selects the compression algorithm automatically at startup, with no configuration required:

1. If the [`brotli`](https://pypi.org/project/brotli/) package is installed, the client uses brotli compression at quality level 6 (out of a maximum of 11) and sends the `Content-Encoding: br` header. Quality 6 is a deliberate trade-off: it compresses better than gzip while keeping CPU overhead low compared to maximum-quality brotli.
2. Otherwise, it falls back to gzip compression and sends the `Content-Encoding: gzip` header.

The server supports both algorithms and decompresses the request body transparently.

## Enabling brotli

Brotli is available as an optional extra. Install it alongside the client:

```bash
pip install "apify-client[brotli]"
# or
uv add "apify-client[brotli]"
```

Once installed, the client detects it at startup and switches to brotli automatically — no code changes needed. To revert to gzip, uninstall the extra.

## Comparison

| | Brotli | Gzip |
|-------------------------------|----------------------------------------|---------------------------------|
| **Compression ratio** | Better than gzip at quality 6 | Good |
| **CPU cost** | Moderate at quality 6 | Low |
| **Availability** | Requires the `brotli` extra | Built-in — no extra needed |
| **`Content-Encoding` header** | `br` | `gzip` |
| **Best for** | Large payloads where bandwidth matters | Minimal-dependency environments |

:::tip
For most workloads, the bandwidth savings from brotli outweigh the small CPU overhead. Install the `brotli` extra unless you are running in an environment where installing additional packages is not feasible.
:::
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ dependencies = [
"pydantic[email]>=2.11.0",
]

[project.optional-dependencies]
brotli = [
"brotli>=1.0.9",
]

[project.urls]
"Apify Homepage" = "https://apify.com"
"Homepage" = "https://docs.apify.com/api/client/python/"
Expand Down
29 changes: 24 additions & 5 deletions src/apify_client/http_clients/_base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import functools
import gzip
import json as jsonlib
import os
Expand All @@ -23,10 +24,20 @@
from apify_client._utils import to_seconds

if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator, Mapping
from collections.abc import AsyncIterator, Callable, Iterator, Mapping

from apify_client.types import JsonSerializable, Timeout

_brotli_compress: Callable[[bytes | bytearray], bytes] | None = None

if not TYPE_CHECKING:
try:
import brotli

_brotli_compress = functools.partial(brotli.compress, quality=6)
except ImportError:
pass


@docs_group('HTTP clients')
@runtime_checkable
Expand Down Expand Up @@ -203,22 +214,30 @@ def _prepare_request_call(
data: str | bytes | bytearray | None = None,
json: JsonSerializable | None = None,
) -> tuple[dict[str, str], dict[str, Any] | None, bytes | None]:
"""Prepare headers, params, and body for an HTTP request. Serializes JSON and applies gzip compression."""
"""Prepare headers, params, and body for an HTTP request. Serializes JSON and compresses the body.

Uses brotli compression (`Content-Encoding: br`) when `brotli` is installed,
otherwise falls back to gzip (`Content-Encoding: gzip`).
"""
if json is not None and data is not None:
raise ValueError('Cannot pass both "json" and "data" parameters at the same time!')

headers = dict(headers) if headers else {}

# Dump JSON data to string so it can be gzipped.
# Dump JSON data to string so it can be compressed.
if json is not None:
data = jsonlib.dumps(json, ensure_ascii=False, allow_nan=False, default=str).encode('utf-8')
headers['Content-Type'] = 'application/json'

if isinstance(data, (str, bytes, bytearray)):
if isinstance(data, str):
data = data.encode('utf-8')
data = gzip.compress(data)
headers['Content-Encoding'] = 'gzip'
if _brotli_compress is not None:
data = _brotli_compress(data)
Comment thread
mixalturek marked this conversation as resolved.
headers['Content-Encoding'] = 'br'
else:
data = gzip.compress(data)
headers['Content-Encoding'] = 'gzip'

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

Expand Down
71 changes: 48 additions & 23 deletions tests/unit/test_http_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any
from unittest.mock import Mock

import brotli
import impit
import pytest

Expand Down Expand Up @@ -278,7 +279,7 @@ def test_prepare_request_call_with_json() -> None:
headers, _params, data = client._prepare_request_call(json=json_data)

assert headers['Content-Type'] == 'application/json'
assert headers['Content-Encoding'] == 'gzip'
assert headers['Content-Encoding'] == 'br'
assert data is not None
assert isinstance(data, bytes)

Expand All @@ -290,12 +291,10 @@ def test_prepare_request_call_with_empty_dict_json() -> None:
headers, _params, data = client._prepare_request_call(json={})

assert headers['Content-Type'] == 'application/json'
assert headers['Content-Encoding'] == 'gzip'
assert headers['Content-Encoding'] == 'br'
assert data is not None
assert isinstance(data, bytes)
# Verify the gzipped data contains the JSON
decompressed = gzip.decompress(data)
assert decompressed == b'{}'
assert brotli.decompress(data) == b'{}'


def test_prepare_request_call_with_empty_list_json() -> None:
Expand All @@ -305,12 +304,10 @@ def test_prepare_request_call_with_empty_list_json() -> None:
headers, _params, data = client._prepare_request_call(json=[])

assert headers['Content-Type'] == 'application/json'
assert headers['Content-Encoding'] == 'gzip'
assert headers['Content-Encoding'] == 'br'
assert data is not None
assert isinstance(data, bytes)
# Verify the gzipped data contains the JSON
decompressed = gzip.decompress(data)
assert decompressed == b'[]'
assert brotli.decompress(data) == b'[]'


def test_prepare_request_call_with_zero_json() -> None:
Expand All @@ -320,12 +317,10 @@ def test_prepare_request_call_with_zero_json() -> None:
headers, _params, data = client._prepare_request_call(json=0)

assert headers['Content-Type'] == 'application/json'
assert headers['Content-Encoding'] == 'gzip'
assert headers['Content-Encoding'] == 'br'
assert data is not None
assert isinstance(data, bytes)
# Verify the gzipped data contains the JSON
decompressed = gzip.decompress(data)
assert decompressed == b'0'
assert brotli.decompress(data) == b'0'


def test_prepare_request_call_with_false_json() -> None:
Expand All @@ -335,12 +330,10 @@ def test_prepare_request_call_with_false_json() -> None:
headers, _params, data = client._prepare_request_call(json=False)

assert headers['Content-Type'] == 'application/json'
assert headers['Content-Encoding'] == 'gzip'
assert headers['Content-Encoding'] == 'br'
assert data is not None
assert isinstance(data, bytes)
# Verify the gzipped data contains the JSON
decompressed = gzip.decompress(data)
assert decompressed == b'false'
assert brotli.decompress(data) == b'false'


def test_prepare_request_call_with_empty_string_json() -> None:
Expand All @@ -350,12 +343,10 @@ def test_prepare_request_call_with_empty_string_json() -> None:
headers, _params, data = client._prepare_request_call(json='')

assert headers['Content-Type'] == 'application/json'
assert headers['Content-Encoding'] == 'gzip'
assert headers['Content-Encoding'] == 'br'
assert data is not None
assert isinstance(data, bytes)
# Verify the gzipped data contains the JSON
decompressed = gzip.decompress(data)
assert decompressed == b'""'
assert brotli.decompress(data) == b'""'


def test_prepare_request_call_with_string_data() -> None:
Expand All @@ -364,7 +355,7 @@ def test_prepare_request_call_with_string_data() -> None:

headers, _params, data = client._prepare_request_call(data='test string')

assert headers['Content-Encoding'] == 'gzip'
assert headers['Content-Encoding'] == 'br'
assert isinstance(data, bytes)


Expand All @@ -374,10 +365,20 @@ def test_prepare_request_call_with_bytes_data() -> None:

headers, _params, data = client._prepare_request_call(data=b'test bytes')

assert headers['Content-Encoding'] == 'gzip'
assert headers['Content-Encoding'] == 'br'
assert isinstance(data, bytes)


def test_prepare_request_call_with_bytearray_data() -> None:
"""bytearray body is compressed correctly."""
client = _ConcreteHttpClient()
headers, _, data = client._prepare_request_call(data=bytearray(b'test bytearray'))

assert headers['Content-Encoding'] == 'br'
assert data is not None
assert brotli.decompress(data) == b'test bytearray'


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 @@ -452,3 +453,27 @@ def test_build_url_with_params_mixed() -> None:
assert 'tags=a' in url
assert 'tags=b' in url
assert 'name=test' in url


def test_prepare_request_call_brotli_compression(monkeypatch: pytest.MonkeyPatch) -> None:
"""When brotli is installed, request body uses brotli (Content-Encoding: br)."""
monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', brotli.compress)

client = _ConcreteHttpClient()
headers, _, data = client._prepare_request_call(json={'k': 'v'})

assert headers['Content-Encoding'] == 'br'
assert data is not None
assert brotli.decompress(data) == b'{"k": "v"}'


def test_prepare_request_call_gzip_fallback_without_brotli(monkeypatch: pytest.MonkeyPatch) -> None:
"""When no brotli library is available, request body falls back to gzip (Content-Encoding: gzip)."""
monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', None)

client = _ConcreteHttpClient()
headers, _, data = client._prepare_request_call(json={'k': 'v'})

assert headers['Content-Encoding'] == 'gzip'
assert data is not None
assert gzip.decompress(data) == b'{"k": "v"}'
6 changes: 5 additions & 1 deletion tests/unit/test_run_charge.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
from typing import TYPE_CHECKING

import brotli
import pytest
from werkzeug import Request, Response

Expand All @@ -18,7 +19,10 @@

def _decode_body(request: Request) -> dict:
raw = request.get_data()
if request.headers.get('Content-Encoding') == 'gzip':
encoding = request.headers.get('Content-Encoding')
if encoding == 'br':
raw = brotli.decompress(raw)
elif encoding == 'gzip':
raw = gzip.decompress(raw)
return json.loads(raw)

Expand Down
Loading
Loading