Skip to content

Commit 5e7f697

Browse files
committed
fix: offload async request body compression to a worker thread
1 parent 5ad7c91 commit 5e7f697

2 files changed

Lines changed: 67 additions & 7 deletions

File tree

src/apify_client/http_clients/_impit.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -396,12 +396,24 @@ async def call(
396396

397397
self._statistics.calls += 1
398398

399-
prepared_headers, prepared_params, content = self._prepare_request_call(
400-
headers=headers,
401-
params=params,
402-
data=data,
403-
json=json,
404-
)
399+
# Serializing and compressing a request body is CPU-bound and would block the event loop, so
400+
# offload request preparation to a worker thread whenever there is a body to compress. Bodyless
401+
# requests skip the thread hop, as they have no expensive work to move off the loop.
402+
if json is not None or data is not None:
403+
prepared_headers, prepared_params, content = await asyncio.to_thread(
404+
self._prepare_request_call,
405+
headers=headers,
406+
params=params,
407+
data=data,
408+
json=json,
409+
)
410+
else:
411+
prepared_headers, prepared_params, content = self._prepare_request_call(
412+
headers=headers,
413+
params=params,
414+
data=data,
415+
json=json,
416+
)
405417

406418
return await self._retry_with_exp_backoff(
407419
lambda stop_retrying, attempt: self._make_request(

tests/unit/test_http_clients.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import gzip
5+
import threading
46
import time
57
from datetime import UTC, datetime, timedelta
68
from typing import TYPE_CHECKING
7-
from unittest.mock import Mock
9+
from unittest.mock import AsyncMock, Mock
810

911
import brotli
1012
import impit
@@ -14,6 +16,7 @@
1416
from apify_client.errors import InvalidResponseBodyError
1517
from apify_client.http_clients import HttpClient, HttpClientAsync, HttpResponse, ImpitHttpClient, ImpitHttpClientAsync
1618
from apify_client.http_clients._impit import _is_retryable_error
19+
from apify_client.http_compressors._base import HttpCompressor
1720
from apify_client.http_compressors._brotli import BrotliHttpCompressor
1821
from apify_client.http_compressors._gzip import GzipHttpCompressor
1922

@@ -483,3 +486,48 @@ def test_build_url_with_params_mixed() -> None:
483486
assert 'tags=a' in url
484487
assert 'tags=b' in url
485488
assert 'name=test' in url
489+
490+
491+
class _ThreadRecordingCompressor(HttpCompressor):
492+
"""Compressor that records the thread `compress` ran on, to prove the work is offloaded."""
493+
494+
content_encoding = 'gzip'
495+
496+
def __init__(self) -> None:
497+
self.compress_thread_id: int | None = None
498+
499+
def compress(self, data: bytes) -> bytes:
500+
self.compress_thread_id = threading.get_ident()
501+
return gzip.compress(data)
502+
503+
504+
async def test_async_call_compresses_request_body_off_the_event_loop() -> None:
505+
"""Body serialization and compression must run in a worker thread, not block the event loop."""
506+
compressor = _ThreadRecordingCompressor()
507+
client = ImpitHttpClientAsync(token='test_token', http_compressor=compressor)
508+
client._impit_async_client = Mock(request=AsyncMock(return_value=Mock(status_code=200)))
509+
510+
await client.call(method='POST', url='https://api.test.com/endpoint', json={'key': 'value'})
511+
512+
assert compressor.compress_thread_id is not None
513+
assert compressor.compress_thread_id != threading.get_ident()
514+
515+
516+
async def test_async_call_skips_thread_offload_without_a_body(monkeypatch: pytest.MonkeyPatch) -> None:
517+
"""A bodyless request has nothing to compress, so it must not pay the worker-thread hop."""
518+
client = ImpitHttpClientAsync(token='test_token')
519+
client._impit_async_client = Mock(request=AsyncMock(return_value=Mock(status_code=200)))
520+
521+
offloaded = False
522+
real_to_thread = asyncio.to_thread
523+
524+
async def spy_to_thread(func: Any, /, *args: Any, **kwargs: Any) -> Any:
525+
nonlocal offloaded
526+
offloaded = True
527+
return await real_to_thread(func, *args, **kwargs)
528+
529+
monkeypatch.setattr(asyncio, 'to_thread', spy_to_thread)
530+
531+
await client.call(method='GET', url='https://api.test.com/endpoint')
532+
533+
assert offloaded is False

0 commit comments

Comments
 (0)