|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import asyncio |
3 | 4 | import gzip |
| 5 | +import threading |
4 | 6 | import time |
5 | 7 | from datetime import UTC, datetime, timedelta |
6 | 8 | from typing import TYPE_CHECKING |
7 | | -from unittest.mock import Mock |
| 9 | +from unittest.mock import AsyncMock, Mock |
8 | 10 |
|
9 | 11 | import brotli |
10 | 12 | import impit |
|
14 | 16 | from apify_client.errors import InvalidResponseBodyError |
15 | 17 | from apify_client.http_clients import HttpClient, HttpClientAsync, HttpResponse, ImpitHttpClient, ImpitHttpClientAsync |
16 | 18 | from apify_client.http_clients._impit import _is_retryable_error |
| 19 | +from apify_client.http_compressors._base import HttpCompressor |
17 | 20 | from apify_client.http_compressors._brotli import BrotliHttpCompressor |
18 | 21 | from apify_client.http_compressors._gzip import GzipHttpCompressor |
19 | 22 |
|
@@ -483,3 +486,48 @@ def test_build_url_with_params_mixed() -> None: |
483 | 486 | assert 'tags=a' in url |
484 | 487 | assert 'tags=b' in url |
485 | 488 | 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