Skip to content

Commit aabd87c

Browse files
committed
feat: Compress requests using brotli algo
- apify/apify-core#28971 added support for brotli compression to BE. - Pros: higher compression, cons: more CPU intensive. - The brotli dependencies are defined as optional, one has to explicitly enable it and choose one. If none is available, the code falls back to gzip. - JS client doesn't compress requests that are too small, this Python client compresses just everything. No change done, I only noticed and stating it.
1 parent 9b77b10 commit aabd87c

6 files changed

Lines changed: 331 additions & 31 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@
4848

4949
or any other Python package manager that consumes PyPI.
5050

51+
To enable brotli request-body compression (better than gzip, especially for large payloads), install the optional extra:
52+
53+
```bash
54+
pip install "apify-client[brotli]"
55+
# or
56+
uv add "apify-client[brotli]"
57+
```
58+
59+
Without this extra the client falls back to gzip automatically — no code changes needed.
5160

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

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ dependencies = [
3030
"pydantic[email]>=2.11.0",
3131
]
3232

33+
[project.optional-dependencies]
34+
brotli = [
35+
"brotli>=1.0.9; platform_python_implementation == 'CPython'",
36+
"brotlicffi>=1.0.9; platform_python_implementation != 'CPython'",
37+
]
38+
3339
[project.urls]
3440
"Apify Homepage" = "https://apify.com"
3541
"Homepage" = "https://docs.apify.com/api/client/python/"
@@ -203,7 +209,7 @@ exclude = ["website/versioned_docs"]
203209
unused-ignore-comment = "error"
204210

205211
[[tool.ty.overrides]]
206-
include = ["docs/**/*.py", "website/**/*.py"]
212+
include = ["docs/**/*.py", "website/**/*.py", "tests/unit/test_http_clients.py"]
207213
[tool.ty.overrides.rules]
208214
unresolved-import = "ignore"
209215

src/apify_client/http_clients/_base.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,25 @@
2323
from apify_client._utils import to_seconds
2424

2525
if TYPE_CHECKING:
26-
from collections.abc import AsyncIterator, Iterator, Mapping
26+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
2727

2828
from apify_client.types import JsonSerializable, Timeout
2929

30+
_brotli_compress: Callable[[bytes | bytearray], bytes] | None = None
31+
32+
if not TYPE_CHECKING:
33+
try:
34+
import brotli
35+
36+
_brotli_compress = brotli.compress
37+
except ImportError:
38+
try:
39+
import brotlicffi
40+
41+
_brotli_compress = brotlicffi.compress
42+
except ImportError:
43+
pass
44+
3045

3146
@docs_group('HTTP clients')
3247
@runtime_checkable
@@ -203,22 +218,30 @@ def _prepare_request_call(
203218
data: str | bytes | bytearray | None = None,
204219
json: JsonSerializable | None = None,
205220
) -> tuple[dict[str, str], dict[str, Any] | None, bytes | None]:
206-
"""Prepare headers, params, and body for an HTTP request. Serializes JSON and applies gzip compression."""
221+
"""Prepare headers, params, and body for an HTTP request. Serializes JSON and compresses the body.
222+
223+
Uses brotli compression (`Content-Encoding: br`) when `brotli` or `brotlicffi` is installed,
224+
otherwise falls back to gzip (`Content-Encoding: gzip`).
225+
"""
207226
if json is not None and data is not None:
208227
raise ValueError('Cannot pass both "json" and "data" parameters at the same time!')
209228

210229
headers = dict(headers) if headers else {}
211230

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

217236
if isinstance(data, (str, bytes, bytearray)):
218237
if isinstance(data, str):
219238
data = data.encode('utf-8')
220-
data = gzip.compress(data)
221-
headers['Content-Encoding'] = 'gzip'
239+
if _brotli_compress is not None:
240+
data = _brotli_compress(data)
241+
headers['Content-Encoding'] = 'br'
242+
else:
243+
data = gzip.compress(data)
244+
headers['Content-Encoding'] = 'gzip'
222245

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

tests/unit/test_http_clients.py

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,29 @@
1313
from apify_client.http_clients import HttpClient, HttpClientAsync, HttpResponse, ImpitHttpClient, ImpitHttpClientAsync
1414
from apify_client.http_clients._impit import _is_retryable_error
1515

16+
try:
17+
import brotli as _brotli_mod
18+
19+
_EXPECTED_ENCODING = 'br'
20+
21+
def _decompress(data: bytes) -> bytes:
22+
return _brotli_mod.decompress(data)
23+
24+
except ImportError:
25+
try:
26+
import brotlicffi as _brotli_mod
27+
28+
_EXPECTED_ENCODING = 'br'
29+
30+
def _decompress(data: bytes) -> bytes:
31+
return _brotli_mod.decompress(data)
32+
33+
except ImportError:
34+
_EXPECTED_ENCODING = 'gzip'
35+
36+
def _decompress(data: bytes) -> bytes:
37+
return gzip.decompress(data)
38+
1639

1740
class _ConcreteHttpClient(HttpClient):
1841
"""Minimal concrete HttpClient for testing base class helpers."""
@@ -278,7 +301,7 @@ def test_prepare_request_call_with_json() -> None:
278301
headers, _params, data = client._prepare_request_call(json=json_data)
279302

280303
assert headers['Content-Type'] == 'application/json'
281-
assert headers['Content-Encoding'] == 'gzip'
304+
assert headers['Content-Encoding'] == _EXPECTED_ENCODING
282305
assert data is not None
283306
assert isinstance(data, bytes)
284307

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

292315
assert headers['Content-Type'] == 'application/json'
293-
assert headers['Content-Encoding'] == 'gzip'
316+
assert headers['Content-Encoding'] == _EXPECTED_ENCODING
294317
assert data is not None
295318
assert isinstance(data, bytes)
296-
# Verify the gzipped data contains the JSON
297-
decompressed = gzip.decompress(data)
298-
assert decompressed == b'{}'
319+
assert _decompress(data) == b'{}'
299320

300321

301322
def test_prepare_request_call_with_empty_list_json() -> None:
@@ -305,12 +326,10 @@ def test_prepare_request_call_with_empty_list_json() -> None:
305326
headers, _params, data = client._prepare_request_call(json=[])
306327

307328
assert headers['Content-Type'] == 'application/json'
308-
assert headers['Content-Encoding'] == 'gzip'
329+
assert headers['Content-Encoding'] == _EXPECTED_ENCODING
309330
assert data is not None
310331
assert isinstance(data, bytes)
311-
# Verify the gzipped data contains the JSON
312-
decompressed = gzip.decompress(data)
313-
assert decompressed == b'[]'
332+
assert _decompress(data) == b'[]'
314333

315334

316335
def test_prepare_request_call_with_zero_json() -> None:
@@ -320,12 +339,10 @@ def test_prepare_request_call_with_zero_json() -> None:
320339
headers, _params, data = client._prepare_request_call(json=0)
321340

322341
assert headers['Content-Type'] == 'application/json'
323-
assert headers['Content-Encoding'] == 'gzip'
342+
assert headers['Content-Encoding'] == _EXPECTED_ENCODING
324343
assert data is not None
325344
assert isinstance(data, bytes)
326-
# Verify the gzipped data contains the JSON
327-
decompressed = gzip.decompress(data)
328-
assert decompressed == b'0'
345+
assert _decompress(data) == b'0'
329346

330347

331348
def test_prepare_request_call_with_false_json() -> None:
@@ -335,12 +352,10 @@ def test_prepare_request_call_with_false_json() -> None:
335352
headers, _params, data = client._prepare_request_call(json=False)
336353

337354
assert headers['Content-Type'] == 'application/json'
338-
assert headers['Content-Encoding'] == 'gzip'
355+
assert headers['Content-Encoding'] == _EXPECTED_ENCODING
339356
assert data is not None
340357
assert isinstance(data, bytes)
341-
# Verify the gzipped data contains the JSON
342-
decompressed = gzip.decompress(data)
343-
assert decompressed == b'false'
358+
assert _decompress(data) == b'false'
344359

345360

346361
def test_prepare_request_call_with_empty_string_json() -> None:
@@ -350,12 +365,10 @@ def test_prepare_request_call_with_empty_string_json() -> None:
350365
headers, _params, data = client._prepare_request_call(json='')
351366

352367
assert headers['Content-Type'] == 'application/json'
353-
assert headers['Content-Encoding'] == 'gzip'
368+
assert headers['Content-Encoding'] == _EXPECTED_ENCODING
354369
assert data is not None
355370
assert isinstance(data, bytes)
356-
# Verify the gzipped data contains the JSON
357-
decompressed = gzip.decompress(data)
358-
assert decompressed == b'""'
371+
assert _decompress(data) == b'""'
359372

360373

361374
def test_prepare_request_call_with_string_data() -> None:
@@ -364,7 +377,7 @@ def test_prepare_request_call_with_string_data() -> None:
364377

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

367-
assert headers['Content-Encoding'] == 'gzip'
380+
assert headers['Content-Encoding'] == _EXPECTED_ENCODING
368381
assert isinstance(data, bytes)
369382

370383

@@ -374,7 +387,7 @@ def test_prepare_request_call_with_bytes_data() -> None:
374387

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

377-
assert headers['Content-Encoding'] == 'gzip'
390+
assert headers['Content-Encoding'] == _EXPECTED_ENCODING
378391
assert isinstance(data, bytes)
379392

380393

@@ -452,3 +465,41 @@ def test_build_url_with_params_mixed() -> None:
452465
assert 'tags=a' in url
453466
assert 'tags=b' in url
454467
assert 'name=test' in url
468+
469+
470+
def test_prepare_request_call_brotli_compression(monkeypatch: pytest.MonkeyPatch) -> None:
471+
"""When a brotli compressor is available, request body uses brotli (Content-Encoding: br)."""
472+
brotlicffi = pytest.importorskip('brotlicffi')
473+
monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', brotlicffi.compress)
474+
475+
client = _ConcreteHttpClient()
476+
headers, _, data = client._prepare_request_call(json={'k': 'v'})
477+
478+
assert headers['Content-Encoding'] == 'br'
479+
assert data is not None
480+
assert brotlicffi.decompress(data) == b'{"k": "v"}'
481+
482+
483+
def test_prepare_request_call_brotli_library_compression(monkeypatch: pytest.MonkeyPatch) -> None:
484+
"""When the brotli (C extension) library is available, request body uses brotli (Content-Encoding: br)."""
485+
brotli = pytest.importorskip('brotli')
486+
monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', brotli.compress)
487+
488+
client = _ConcreteHttpClient()
489+
headers, _, data = client._prepare_request_call(json={'k': 'v'})
490+
491+
assert headers['Content-Encoding'] == 'br'
492+
assert data is not None
493+
assert brotli.decompress(data) == b'{"k": "v"}'
494+
495+
496+
def test_prepare_request_call_gzip_fallback_without_brotli(monkeypatch: pytest.MonkeyPatch) -> None:
497+
"""When no brotli library is available, request body falls back to gzip (Content-Encoding: gzip)."""
498+
monkeypatch.setattr('apify_client.http_clients._base._brotli_compress', None)
499+
500+
client = _ConcreteHttpClient()
501+
headers, _, data = client._prepare_request_call(json={'k': 'v'})
502+
503+
assert headers['Content-Encoding'] == 'gzip'
504+
assert data is not None
505+
assert gzip.decompress(data) == b'{"k": "v"}'

tests/unit/test_run_charge.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@
44
import json
55
from typing import TYPE_CHECKING
66

7+
try:
8+
import brotlicffi as _brotli_mod
9+
10+
def _decompress(data: bytes) -> bytes:
11+
return _brotli_mod.decompress(data)
12+
13+
except ImportError:
14+
try:
15+
import brotli as _brotli_mod # type: ignore[no-redef]
16+
17+
def _decompress(data: bytes) -> bytes:
18+
return _brotli_mod.decompress(data)
19+
20+
except ImportError:
21+
22+
def _decompress(data: bytes) -> bytes:
23+
return gzip.decompress(data)
24+
725
import pytest
826
from werkzeug import Request, Response
927

@@ -18,7 +36,10 @@
1836

1937
def _decode_body(request: Request) -> dict:
2038
raw = request.get_data()
21-
if request.headers.get('Content-Encoding') == 'gzip':
39+
encoding = request.headers.get('Content-Encoding')
40+
if encoding == 'br':
41+
raw = _decompress(raw)
42+
elif encoding == 'gzip':
2243
raw = gzip.decompress(raw)
2344
return json.loads(raw)
2445

0 commit comments

Comments
 (0)