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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just wondering whether this should really be the only way to configure the compression method (only via installation of extra), or whether we should introduce a new flag on the ApifyClient base class instead.

@Pijukatel WDYT?


```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
6 changes: 6 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 Expand Up @@ -62,6 +67,7 @@ dev = [
"setuptools", # setuptools are used by pytest but not explicitly required
"types-colorama<0.5.0",
"ty~=0.0.0",
"brotli>=1.0.9",
"werkzeug<4.0.0", # Werkzeug is used by pytest-httpserver
]

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