Skip to content

Commit 8c0f0a6

Browse files
committed
fix: Respect caller-supplied Content-Encoding for pre-compressed request bodies
1 parent 6bd31b2 commit 8c0f0a6

7 files changed

Lines changed: 206 additions & 54 deletions

File tree

docs/02_concepts/13_http_compression.mdx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import CodeBlock from '@theme/CodeBlock';
1010

1111
import SkipCompressionAsyncExample from '!!raw-loader!./code/13_skip_compression_async.py';
1212
import SkipCompressionSyncExample from '!!raw-loader!./code/13_skip_compression_sync.py';
13+
import PrecompressedAsyncExample from '!!raw-loader!./code/13_precompressed_async.py';
14+
import PrecompressedSyncExample from '!!raw-loader!./code/13_precompressed_sync.py';
1315

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

1618
## How it works
1719

18-
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.
20+
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's large enough to benefit, its content type isn't already compressed, and the request carries no `Content-Encoding` of its own. For details, see [Minimum body size](#minimum-body-size), [Already-compressed payloads](#already-compressed-payloads), and [Pre-compressed bodies](#pre-compressed-bodies).
1921

2022
## Minimum body size
2123

@@ -47,6 +49,27 @@ Two kinds of media type are compressed anyway: raw formats such as `image/bmp`,
4749

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

52+
## Pre-compressed bodies
53+
54+
A payload can reach the client already encoded, for example a gzipped file read from disk. Set the `Content-Encoding` header to name the encoding the payload carries. The client then sends the body as it is and forwards the header, so nothing gets compressed twice. `set_record` exposes the header as its `content_encoding` argument:
55+
56+
<Tabs>
57+
<TabItem value="AsyncExample" label="Async client" default>
58+
<CodeBlock className="language-python">
59+
{PrecompressedAsyncExample}
60+
</CodeBlock>
61+
</TabItem>
62+
<TabItem value="SyncExample" label="Sync client">
63+
<CodeBlock className="language-python">
64+
{PrecompressedSyncExample}
65+
</CodeBlock>
66+
</TabItem>
67+
</Tabs>
68+
69+
The header is forwarded verbatim, so it also covers encodings the client ships no compressor for, such as `deflate`. The API accepts `gzip`, `br`, `deflate`, and `identity`. Passing `identity` turns compression off for a single request without changing how the client is configured.
70+
71+
The client can't verify that the body matches the header, so set `Content-Encoding` only when the payload really is encoded that way. Key-value store records are stored exactly as you upload them, which makes the header part of the stored record rather than a transport detail.
72+
5073
## Configuration
5174

5275
To choose the compression algorithm, pass `compression` to the client constructor:
@@ -88,7 +111,7 @@ client = ApifyClient(token='MY-APIFY-TOKEN', compression=BrotliHttpCompressor(qu
88111
client = ApifyClient(token='MY-APIFY-TOKEN', compression=GzipHttpCompressor(quality=9))
89112
```
90113

91-
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):
114+
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) or [pre-compressed by the caller](#pre-compressed-bodies):
92115

93116
```python
94117
from apify_client import ApifyClient
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import asyncio
2+
import gzip
3+
from pathlib import Path
4+
5+
from apify_client import ApifyClientAsync
6+
7+
TOKEN = 'MY-APIFY-TOKEN'
8+
9+
10+
async def main() -> None:
11+
apify_client = ApifyClientAsync(TOKEN)
12+
kvs_client = apify_client.key_value_store('MY-KVS-ID')
13+
14+
report = await asyncio.to_thread(Path('report.csv').read_bytes)
15+
compressed_report = await asyncio.to_thread(gzip.compress, report)
16+
17+
# The explicit content encoding stops the client from compressing the bytes again.
18+
await kvs_client.set_record(
19+
'report',
20+
compressed_report,
21+
content_type='text/csv',
22+
content_encoding='gzip',
23+
)
24+
25+
26+
if __name__ == '__main__':
27+
asyncio.run(main())
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import gzip
2+
from pathlib import Path
3+
4+
from apify_client import ApifyClient
5+
6+
TOKEN = 'MY-APIFY-TOKEN'
7+
8+
9+
def main() -> None:
10+
apify_client = ApifyClient(TOKEN)
11+
kvs_client = apify_client.key_value_store('MY-KVS-ID')
12+
13+
report = Path('report.csv').read_bytes()
14+
compressed_report = gzip.compress(report)
15+
16+
# The explicit content encoding stops the client from compressing the bytes again.
17+
kvs_client.set_record(
18+
'report',
19+
compressed_report,
20+
content_type='text/csv',
21+
content_encoding='gzip',
22+
)

src/apify_client/_resource_clients/key_value_store.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ def set_record(
360360
value: Any,
361361
*,
362362
content_type: str | None = None,
363+
content_encoding: str | None = None,
363364
timeout: Timeout = 'long',
364365
) -> None:
365366
"""Set a value to the given record in the key-value store.
@@ -370,11 +371,17 @@ def set_record(
370371
key: The key of the record to save the value to.
371372
value: The value to save into the record.
372373
content_type: The content type of the saved value.
374+
content_encoding: The encoding already applied to `value`, sent as the `Content-Encoding` header. Pass it
375+
to upload a pre-compressed value - the client then forwards the bytes as they are instead of
376+
compressing them itself. The API accepts `gzip`, `br`, `deflate`, and `identity`, and stores the
377+
record exactly as uploaded, so this also becomes the encoding the record is served with.
373378
timeout: Timeout for the API HTTP request.
374379
"""
375380
value, content_type = encode_key_value_store_record_value(value, content_type=content_type)
376381

377382
headers = {'content-type': content_type}
383+
if content_encoding is not None:
384+
headers['content-encoding'] = content_encoding
378385

379386
self._http_client.call(
380387
url=self._build_url(f'records/{key}'),
@@ -776,6 +783,7 @@ async def set_record(
776783
value: Any,
777784
*,
778785
content_type: str | None = None,
786+
content_encoding: str | None = None,
779787
timeout: Timeout = 'long',
780788
) -> None:
781789
"""Set a value to the given record in the key-value store.
@@ -786,11 +794,17 @@ async def set_record(
786794
key: The key of the record to save the value to.
787795
value: The value to save into the record.
788796
content_type: The content type of the saved value.
797+
content_encoding: The encoding already applied to `value`, sent as the `Content-Encoding` header. Pass it
798+
to upload a pre-compressed value - the client then forwards the bytes as they are instead of
799+
compressing them itself. The API accepts `gzip`, `br`, `deflate`, and `identity`, and stores the
800+
record exactly as uploaded, so this also becomes the encoding the record is served with.
789801
timeout: Timeout for the API HTTP request.
790802
"""
791803
value, content_type = encode_key_value_store_record_value(value, content_type=content_type)
792804

793805
headers = {'content-type': content_type}
806+
if content_encoding is not None:
807+
headers['content-encoding'] = content_encoding
794808

795809
await self._http_client.call(
796810
url=self._build_url(f'records/{key}'),

src/apify_client/http_clients/_base.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,11 @@ def _merge_headers(base: dict[str, str] | None, override: dict[str, str] | None)
170170
merged[key] = value
171171
return merged
172172

173+
@staticmethod
174+
def _get_header(headers: dict[str, str], name: str) -> str | None:
175+
"""Look up a header value by name, treated case-insensitively. Returns `None` if the header is not set."""
176+
return next((value for key, value in headers.items() if key.lower() == name.lower()), None)
177+
173178
@staticmethod
174179
def _parse_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
175180
"""Convert request parameters to Apify API-compatible formats.
@@ -228,9 +233,9 @@ def _compute_timeout(self, timeout: Timeout, *, attempt: int) -> int | float | N
228233
def _is_body_worth_compressing(data: str | bytes | bytearray | None) -> bool:
229234
"""Whether this body clears the size threshold `_prepare_request_call` compresses at, cheaply.
230235
231-
Below the threshold nothing is ever compressed. At or above it the content type still decides, but
232-
checking that here would buy nothing - a body that turns out to be already compressed only wastes the
233-
thread hop this answer guards.
236+
Below the threshold nothing is ever compressed. At or above it the content type and a caller-supplied
237+
`Content-Encoding` still decide, but checking those here would buy nothing - a body that turns out to be
238+
already encoded only wastes the thread hop this answer guards.
234239
235240
The threshold is measured on encoded bytes, so a character count alone cannot decide a `str`. It is a
236241
lower bound, so a `str` long enough in characters is long enough in bytes too. Below that the encoded
@@ -252,12 +257,15 @@ def _prepare_request_call(
252257
) -> tuple[dict[str, str], dict[str, Any] | None, bytes | None]:
253258
"""Prepare headers, params, and body for an HTTP request.
254259
255-
Merges the client's default headers (including authorization) with per-request headers, serializes JSON
256-
and compresses the body unless it is smaller than `MIN_COMPRESSION_SIZE` or its content type says the
257-
payload is already compressed. Header names are treated case-insensitively and per-request values win
258-
over the client defaults. For JSON bodies, a `Content-Type` header is set unless the caller supplied one.
259-
`Content-Encoding` always describes what was actually applied to the body, so a caller-supplied value is
260-
dropped whenever nothing was compressed.
260+
Merges the client's default headers (including authorization) with per-request headers and serializes a
261+
JSON body. Header names are treated case-insensitively and per-request values win over the client
262+
defaults. For JSON bodies, a `Content-Type` header is set unless the caller supplied one.
263+
264+
The body is compressed unless a `Content-Encoding` header is already set, the body is smaller than
265+
`MIN_COMPRESSION_SIZE`, or its content type says the payload is already compressed. A caller-supplied
266+
`Content-Encoding` is forwarded verbatim, which is how a pre-encoded body is uploaded - including one in
267+
an encoding the client ships no compressor for. `Content-Encoding: identity` therefore opts a single
268+
request out of compression.
261269
"""
262270
if json is not None and data is not None:
263271
raise ValueError('Cannot pass both "json" and "data" parameters at the same time!')
@@ -267,27 +275,24 @@ def _prepare_request_call(
267275
# Dump JSON data to a string so it can be sent as a request body.
268276
if json is not None:
269277
data = jsonlib.dumps(json, ensure_ascii=False, allow_nan=False, default=str).encode('utf-8')
270-
if not any(key.lower() == 'content-type' for key in headers):
278+
if self._get_header(headers, 'content-type') is None:
271279
headers['Content-Type'] = 'application/json'
272280

273-
compressed = False
274-
275281
if isinstance(data, (str, bytes, bytearray)):
276282
if isinstance(data, str):
277283
data = data.encode('utf-8')
278284
elif isinstance(data, bytearray):
279285
data = bytes(data)
280286

281-
content_type = next((value for key, value in headers.items() if key.lower() == 'content-type'), None)
282-
if len(data) >= MIN_COMPRESSION_SIZE and is_compressible_content_type(content_type):
287+
# A caller-supplied encoding says the body arrives already encoded, so compressing it here would
288+
# both mislabel it and waste the work.
289+
if (
290+
self._get_header(headers, 'content-encoding') is None
291+
and len(data) >= MIN_COMPRESSION_SIZE
292+
and is_compressible_content_type(self._get_header(headers, 'content-type'))
293+
):
283294
data = self._http_compressor.compress(data)
284295
headers = self._merge_headers(headers, {'Content-Encoding': self._http_compressor.content_encoding})
285-
compressed = True
286-
287-
# Anything left uncompressed goes out as-is - a file-like body included - so a caller-supplied encoding
288-
# would misdescribe it.
289-
if data is not None and not compressed:
290-
headers = {key: value for key, value in headers.items() if key.lower() != 'content-encoding'}
291296

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

tests/unit/test_http_clients.py

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -531,33 +531,18 @@ def test_prepare_request_call_skips_compression_for_already_compressed_content(c
531531
assert headers['User-Agent'] == client._headers['User-Agent']
532532

533533

534-
def test_prepare_request_call_drops_caller_content_encoding_when_compression_is_skipped() -> None:
535-
"""Skipping compression also strips a caller-supplied `Content-Encoding`, which would misdescribe the body."""
534+
def test_prepare_request_call_keeps_caller_content_encoding_for_a_streamed_body() -> None:
535+
"""A body the client streams rather than compresses, such as a file-like object, keeps its `Content-Encoding`."""
536536
client = _ConcreteHttpClient(http_compressor=GzipHttpCompressor())
537-
# Above the size threshold, so the content type is what skips compression here.
538-
payload = b'\xff' * MIN_COMPRESSION_SIZE
539-
540-
headers, _params, data = client._prepare_request_call(
541-
headers={'content-type': 'image/jpeg', 'content-encoding': 'br'},
542-
data=payload,
543-
)
544-
545-
assert data == payload
546-
assert not any(key.lower() == 'content-encoding' for key in headers)
547-
548-
549-
def test_prepare_request_call_drops_caller_content_encoding_for_a_streamed_body() -> None:
550-
"""A body that is streamed rather than compressed, such as a file-like object, also loses `Content-Encoding`."""
551-
client = _ConcreteHttpClient(http_compressor=GzipHttpCompressor())
552-
stream = BytesIO(b'raw payload')
537+
stream = BytesIO(gzip.compress(b'raw payload'))
553538

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

559544
assert data is stream
560-
assert not any(key.lower() == 'content-encoding' for key in headers)
545+
assert headers['content-encoding'] == 'gzip'
561546

562547

563548
@pytest.mark.parametrize(
@@ -645,27 +630,44 @@ def test_prepare_request_call_json_keeps_caller_content_type() -> None:
645630
assert content_type_headers == {'content-type': 'application/json; charset=utf-8'}
646631

647632

648-
def test_prepare_request_call_replaces_caller_content_encoding() -> None:
649-
"""A compressed body reports the compressor actually applied, replacing any caller-supplied Content-Encoding."""
633+
@pytest.mark.parametrize(
634+
('caller_headers', 'body'),
635+
[
636+
pytest.param({'content-encoding': 'br'}, b'x' * MIN_COMPRESSION_SIZE, id='body the client would compress'),
637+
pytest.param({'content-encoding': 'br'}, b'payload', id='body below the size threshold'),
638+
pytest.param(
639+
{'content-encoding': 'br', 'content-type': 'image/jpeg'},
640+
b'\xff' * MIN_COMPRESSION_SIZE,
641+
id='already-compressed content type',
642+
),
643+
pytest.param({'content-encoding': 'identity'}, b'x' * MIN_COMPRESSION_SIZE, id='identity opt-out'),
644+
pytest.param(
645+
{'content-encoding': 'deflate'},
646+
b'x' * MIN_COMPRESSION_SIZE,
647+
id='encoding the client has no compressor for',
648+
),
649+
],
650+
)
651+
def test_prepare_request_call_keeps_caller_content_encoding(caller_headers: dict[str, str], body: bytes) -> None:
652+
"""A caller-supplied `Content-Encoding` marks the body as pre-encoded, so it goes out untouched and labeled."""
650653
client = _ConcreteHttpClient(http_compressor=GzipHttpCompressor())
651654

652-
headers, _params, _data = client._prepare_request_call(
653-
headers={'content-encoding': 'br'},
654-
data='x' * MIN_COMPRESSION_SIZE,
655-
)
655+
headers, _params, data = client._prepare_request_call(headers=caller_headers, data=body)
656656

657+
assert data == body
657658
encoding_headers = {key: value for key, value in headers.items() if key.lower() == 'content-encoding'}
658-
assert encoding_headers == {'Content-Encoding': 'gzip'}
659+
assert encoding_headers == {'content-encoding': caller_headers['content-encoding']}
659660

660661

661-
def test_prepare_request_call_drops_caller_content_encoding_when_skipping_compression() -> None:
662-
"""A caller-supplied Content-Encoding is dropped for an uncompressed body, so it cannot mislabel it."""
663-
client = _ConcreteHttpClient(http_compressor=GzipHttpCompressor())
662+
def test_prepare_request_call_keeps_client_wide_content_encoding() -> None:
663+
"""A `Content-Encoding` configured on the client counts as caller-supplied on every request it sends."""
664+
client = _ConcreteHttpClient(headers={'Content-Encoding': 'identity'}, http_compressor=GzipHttpCompressor())
665+
body = b'x' * MIN_COMPRESSION_SIZE
664666

665-
headers, _params, data = client._prepare_request_call(headers={'content-encoding': 'br'}, data='payload')
667+
headers, _params, data = client._prepare_request_call(data=body)
666668

667-
assert data == b'payload'
668-
assert not any(key.lower() == 'content-encoding' for key in headers)
669+
assert data == body
670+
assert headers['Content-Encoding'] == 'identity'
669671

670672

671673
def test_build_url_with_params_none() -> None:

0 commit comments

Comments
 (0)