diff --git a/docs/02_concepts/14_server_actors.mdx b/docs/02_concepts/14_server_actors.mdx
new file mode 100644
index 00000000..191bffc4
--- /dev/null
+++ b/docs/02_concepts/14_server_actors.mdx
@@ -0,0 +1,80 @@
+---
+id: server-actors
+title: Server Actors
+description: Send HTTP requests to Server Actors, and get their URL and OpenAPI specification.
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import CodeBlock from '@theme/CodeBlock';
+import ApiLink from '@theme/ApiLink';
+
+import ServerAsyncExample from '!!raw-loader!./code/14_server_async.py';
+import ServerSyncExample from '!!raw-loader!./code/14_server_sync.py';
+import ServerOtherClientAsyncExample from '!!raw-loader!./code/14_server_other_client_async.py';
+import ServerOtherClientSyncExample from '!!raw-loader!./code/14_server_other_client_sync.py';
+
+A [Server Actor](https://docs.apify.com/platform/actors/running/standby) runs a web server. You call it over HTTP, and the platform keeps a run ready to answer, so each call skips the run startup. To send a request to such an Actor, use `ActorClient.server_request`. It takes the path on the Actor's server, and optionally the HTTP method, query parameters, headers, and a body:
+
+
+
+
+ {ServerAsyncExample}
+
+
+
+
+ {ServerSyncExample}
+
+
+
+
+The method returns the server's response. To find out which endpoints an Actor serves, see its page in [Apify Store](https://apify.com/store) or its README.
+
+## How the request is sent
+
+Note that:
+
+- The client takes the server's URL from the Actor's `standby_url`. It fetches the Actor from the API on the first call and reuses the URL for later calls on the same `ActorClient`. If the Actor isn't a Server Actor, the method raises an `ApifyClientError`.
+- The path is always resolved against that URL, so a path can't send the request to another host.
+- By default, the request body is sent uncompressed, since the Actor's server may not decode a compressed body. If the server decodes it, pass `compression`, for example `compression='gzip'`. The client then compresses large bodies the same way as for API calls.
+
+## Calling the server with another HTTP client
+
+For an Actor with many endpoints, you may prefer a client generated from the server's OpenAPI specification, or an HTTP client you already use. Two methods give such a client what it needs:
+
+- `ActorClient.get_server_url` returns the server's URL. It fetches and reuses the URL the same way as `server_request`.
+- `ActorClient.get_server_openapi_spec` returns the server's OpenAPI specification as a `dict`. It's the [web server schema](https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema) from the Actor's definition, taken from the build the Actor runs in Server mode. That's the build set in the Actor's Standby settings, or `latest` if none is set. If the Actor doesn't define a web server schema, the method returns `None`.
+
+A request from another client needs your Apify token in the `Authorization` header, and a timeout long enough for a server run to start. For details, see [Authentication](#authentication) and [Timeouts, retries, and errors](#timeouts-retries-and-errors). The following example uses [HTTPX2](https://github.com/pydantic/httpx2), which you can install with the `httpx2` extra:
+
+```sh
+pip install "apify-client[httpx2]"
+```
+
+
+
+
+ {ServerOtherClientAsyncExample}
+
+
+
+
+ {ServerOtherClientSyncExample}
+
+
+
+
+## Authentication
+
+The request carries the client's API token in the `Authorization` header. The platform removes the token before it forwards the request, so the token doesn't reach the Actor's code.
+
+If the Actor reads the `Authorization` header for its own authentication, pass that header in `headers`, and send your Apify token in the `X-Apify-Authorization` header instead. A header you pass in `headers` replaces the client's `Authorization` header for that request.
+
+For details, see [How do I authenticate my requests](https://docs.apify.com/platform/actors/running/standby#how-do-i-authenticate-my-requests) in the platform documentation.
+
+## Timeouts, retries, and errors
+
+When no server run is ready, the first request waits for the platform to start one, which can take a few minutes. The method uses the `long` timeout tier by default for that reason. You can pass a different `timeout`, the same way as for API calls. For details, see [Timeouts](./11_timeouts.mdx).
+
+An error status from the Actor's server raises an `ApifyApiError`, like an error from the API. Server errors and rate-limit responses are retried with the client's retry settings, so a request that the server failed to answer may reach it more than once. If your endpoint isn't safe to call twice, lower `max_retries` on the client. For details, see [Retries](./05_retries.mdx) and [Error handling](./04_error_handling.mdx).
diff --git a/docs/02_concepts/code/14_server_async.py b/docs/02_concepts/code/14_server_async.py
new file mode 100644
index 00000000..4d8edb47
--- /dev/null
+++ b/docs/02_concepts/code/14_server_async.py
@@ -0,0 +1,20 @@
+from apify_client import ApifyClientAsync
+
+TOKEN = 'MY-APIFY-TOKEN'
+
+
+async def main() -> None:
+ apify_client = ApifyClientAsync(TOKEN)
+ actor_client = apify_client.actor('username/actor-name')
+
+ # Send a GET request to the /search endpoint of the Actor's server.
+ response = await actor_client.server_request('/search', params={'q': 'apify'})
+ print(response.json())
+
+ # Send a POST request with a JSON body.
+ response = await actor_client.server_request(
+ '/items',
+ method='POST',
+ json={'url': 'https://apify.com'},
+ )
+ print(response.status_code)
diff --git a/docs/02_concepts/code/14_server_other_client_async.py b/docs/02_concepts/code/14_server_other_client_async.py
new file mode 100644
index 00000000..72166e00
--- /dev/null
+++ b/docs/02_concepts/code/14_server_other_client_async.py
@@ -0,0 +1,25 @@
+import httpx2
+
+from apify_client import ApifyClientAsync
+
+TOKEN = 'MY-APIFY-TOKEN'
+
+
+async def main() -> None:
+ apify_client = ApifyClientAsync(TOKEN)
+ actor_client = apify_client.actor('username/actor-name')
+
+ # Get the server's URL and the OpenAPI specification of its endpoints.
+ server_url = await actor_client.get_server_url()
+ spec = await actor_client.get_server_openapi_spec()
+ if spec is not None:
+ print(list(spec['paths']))
+
+ # The first request to an idle Actor waits for a server run to start.
+ async with httpx2.AsyncClient(
+ base_url=server_url,
+ headers={'Authorization': f'Bearer {TOKEN}'},
+ timeout=360,
+ ) as http_client:
+ response = await http_client.get('/search', params={'q': 'apify'})
+ print(response.json())
diff --git a/docs/02_concepts/code/14_server_other_client_sync.py b/docs/02_concepts/code/14_server_other_client_sync.py
new file mode 100644
index 00000000..a0cc7fca
--- /dev/null
+++ b/docs/02_concepts/code/14_server_other_client_sync.py
@@ -0,0 +1,25 @@
+import httpx2
+
+from apify_client import ApifyClient
+
+TOKEN = 'MY-APIFY-TOKEN'
+
+
+def main() -> None:
+ apify_client = ApifyClient(TOKEN)
+ actor_client = apify_client.actor('username/actor-name')
+
+ # Get the server's URL and the OpenAPI specification of its endpoints.
+ server_url = actor_client.get_server_url()
+ spec = actor_client.get_server_openapi_spec()
+ if spec is not None:
+ print(list(spec['paths']))
+
+ # The first request to an idle Actor waits for a server run to start.
+ with httpx2.Client(
+ base_url=server_url,
+ headers={'Authorization': f'Bearer {TOKEN}'},
+ timeout=360,
+ ) as http_client:
+ response = http_client.get('/search', params={'q': 'apify'})
+ print(response.json())
diff --git a/docs/02_concepts/code/14_server_sync.py b/docs/02_concepts/code/14_server_sync.py
new file mode 100644
index 00000000..c56e06bf
--- /dev/null
+++ b/docs/02_concepts/code/14_server_sync.py
@@ -0,0 +1,20 @@
+from apify_client import ApifyClient
+
+TOKEN = 'MY-APIFY-TOKEN'
+
+
+def main() -> None:
+ apify_client = ApifyClient(TOKEN)
+ actor_client = apify_client.actor('username/actor-name')
+
+ # Send a GET request to the /search endpoint of the Actor's server.
+ response = actor_client.server_request('/search', params={'q': 'apify'})
+ print(response.json())
+
+ # Send a POST request with a JSON body.
+ response = actor_client.server_request(
+ '/items',
+ method='POST',
+ json={'url': 'https://apify.com'},
+ )
+ print(response.status_code)
diff --git a/scripts/_utils.py b/scripts/_utils.py
index 48612c54..5be77090 100644
--- a/scripts/_utils.py
+++ b/scripts/_utils.py
@@ -20,7 +20,7 @@
"""Path to the source code of the apify_client package."""
_SUBSTITUTIONS = [
- (re.compile(r'Client'), 'ClientAsync'),
+ (re.compile(r'Client(?!Error)'), 'ClientAsync'),
(re.compile(r'\bsynchronously\b'), 'asynchronously'),
(re.compile(r'\bSynchronously\b'), 'Asynchronously'),
(re.compile(r'\bsynchronous\b'), 'asynchronous'),
diff --git a/src/apify_client/_resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py
index 6bd06fbb..8e6216c6 100644
--- a/src/apify_client/_resource_clients/actor.py
+++ b/src/apify_client/_resource_clients/actor.py
@@ -32,13 +32,16 @@
start_waiting_for_resources,
start_waiting_for_resources_async,
)
+from apify_client.errors import ApifyClientError
+from apify_client.http_compressors._resolve import resolve_compressor
if TYPE_CHECKING:
- from datetime import timedelta
+ from collections.abc import Mapping
+ from datetime import datetime, timedelta
from decimal import Decimal
from logging import Logger
- from apify_client._literals import ActorJobStatus, ActorPermissionLevel, RunOrigin
+ from apify_client._literals import ActorJobStatus, ActorPermissionLevel, HttpMethod, RunOrigin
from apify_client._resource_clients import (
ActorVersionClient,
ActorVersionClientAsync,
@@ -55,7 +58,9 @@
WebhookCollectionClient,
WebhookCollectionClientAsync,
)
- from apify_client.types import Timeout, WebhooksList
+ from apify_client.http_clients import HttpResponse
+ from apify_client.http_compressors import HttpCompressor
+ from apify_client.types import HttpCompressionAlgorithm, JsonSerializable, Timeout, WebhooksList
_PricingInfo = (
PayPerEventActorPricingInfo
@@ -86,6 +91,7 @@ def __init__(
resource_path=resource_path,
**kwargs,
)
+ self._server_url: str | None = None
def get(self, *, timeout: Timeout = 'short') -> ActorResource | None:
"""Retrieve the Actor.
@@ -625,6 +631,142 @@ def validate_input(
return True
+ def get_server_url(self) -> str:
+ """Return the URL of the Actor's server.
+
+ The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this
+ client. Use it to call the server with another HTTP client, for example one generated from the server's
+ OpenAPI specification.
+
+ https://docs.apify.com/platform/actors/running/standby
+
+ Returns:
+ The URL of the Actor's server.
+
+ Raises:
+ ApifyClientError: If the Actor is not a Server Actor.
+ ApifyApiError: If the Actor does not exist.
+ """
+ if self._server_url is None:
+ response = self._http_client.call(url=self._build_url(), method='GET', timeout='short')
+ actor = ActorResponse.model_validate(response_to_dict(response)).data
+ if actor.standby_url is None:
+ raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.')
+ self._server_url = actor.standby_url
+ return self._server_url
+
+ def get_server_openapi_spec(self) -> dict[str, Any] | None:
+ """Return the OpenAPI specification of the Actor's server.
+
+ The specification is the web server schema from the Actor's definition, taken from the build the Actor runs in
+ Server mode: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP
+ client for the server, and point that client at the URL returned by `get_server_url`.
+
+ https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema
+
+ Returns:
+ The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one.
+
+ Raises:
+ ApifyClientError: If the Actor is not a Server Actor, or its Server mode build doesn't exist.
+ ApifyApiError: If the Actor does not exist.
+ """
+ response = self._http_client.call(url=self._build_url(), method='GET', timeout='short')
+ actor = ActorResponse.model_validate(response_to_dict(response)).data
+ if actor.standby_url is None:
+ raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.')
+
+ build_ref = (actor.actor_standby.build if actor.actor_standby else None) or 'latest'
+
+ build_id = None
+ tagged_build = (actor.tagged_builds or {}).get(build_ref)
+ if tagged_build is not None:
+ build_id = tagged_build.build_id
+ else:
+ # Not a tag, so look `build_ref` up as a build number, which the API can't resolve directly.
+ for build_item in self.builds().iterate(desc=True):
+ if build_item.build_number == build_ref:
+ build_id = build_item.id
+ break
+
+ build = None
+ if build_id is not None:
+ build_client = self._client_registry.build_client(
+ resource_id=build_id,
+ base_url=self._base_url,
+ public_base_url=self._public_base_url,
+ http_client=self._http_client,
+ client_registry=self._client_registry,
+ )
+ build = build_client.get()
+ if build is None:
+ raise ApifyClientError(f'Build {build_ref} of Actor {self.resource_id} does not exist.')
+
+ if build.actor_definition is None:
+ return None
+ return build.actor_definition.web_server_schema
+
+ def server_request(
+ self,
+ path: str = '/',
+ *,
+ method: HttpMethod = 'GET',
+ params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None,
+ headers: dict[str, str] | None = None,
+ data: str | bytes | None = None,
+ json: JsonSerializable | None = None,
+ compression: HttpCompressionAlgorithm | HttpCompressor | None = None,
+ timeout: Timeout = 'long',
+ ) -> HttpResponse:
+ """Send an HTTP request to the Actor's server.
+
+ A Server Actor runs a web server that is called over HTTP, without starting a run for each call. The request
+ goes to the URL returned by `get_server_url` and carries the client's token, which the platform consumes, so it
+ never reaches the Actor's code. The first request to an idle Actor waits for the platform to start a server
+ run, which is why the timeout defaults to `long`.
+
+ A server error or a rate-limit response is retried like an API call, so the server may receive the same
+ request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's
+ server may not decode a compressed body.
+
+ https://docs.apify.com/platform/actors/running/standby
+
+ Args:
+ path: The path on the Actor's server, for example `/search`. It is always resolved against the server URL,
+ so the request, and the token with it, cannot be sent to another host. Pass the query in `params`.
+ method: HTTP method of the request.
+ params: Query parameters of the request, converted the same way as for API calls: booleans become `true`
+ or `false`, a list of strings becomes one comma-separated value, a datetime becomes an ISO 8601 string
+ in UTC, and `None` values are left out.
+ headers: Additional headers of the request.
+ data: Raw request body. Cannot be used together with `json`.
+ json: JSON-serializable request body. Cannot be used together with `data`.
+ compression: Compression algorithm for the request body, as a string literal or an `HttpCompressor`
+ instance. The body is compressed under the same conditions as for API calls. Set it only if the
+ Actor's server decodes the resulting `Content-Encoding`.
+ timeout: Timeout for the HTTP request.
+
+ Returns:
+ The response of the Actor's server.
+
+ Raises:
+ ApifyClientError: If the Actor is not a Server Actor.
+ ApifyApiError: If the Actor does not exist, or the server responds with an error status.
+ ImportError: If `compression` needs an optional extra that is not installed.
+ ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm.
+ """
+ server_url = self.get_server_url()
+ return self._http_client.call(
+ url=f'{server_url.rstrip("/")}/{path.lstrip("/")}',
+ method=method,
+ params=dict(params) if params is not None else None,
+ headers=headers,
+ data=data,
+ json=json,
+ timeout=timeout,
+ compression=resolve_compressor(compression) if compression is not None else False,
+ )
+
@docs_group('Resource clients')
class ActorClientAsync(ResourceClientAsync):
@@ -646,6 +788,7 @@ def __init__(
resource_path=resource_path,
**kwargs,
)
+ self._server_url: str | None = None
async def get(self, *, timeout: Timeout = 'short') -> ActorResource | None:
"""Retrieve the Actor.
@@ -1185,3 +1328,139 @@ async def validate_input(
)
return True
+
+ async def get_server_url(self) -> str:
+ """Return the URL of the Actor's server.
+
+ The URL is the Actor's `standby_url`, fetched from the API on the first call and reused by later calls on this
+ client. Use it to call the server with another HTTP client, for example one generated from the server's
+ OpenAPI specification.
+
+ https://docs.apify.com/platform/actors/running/standby
+
+ Returns:
+ The URL of the Actor's server.
+
+ Raises:
+ ApifyClientError: If the Actor is not a Server Actor.
+ ApifyApiError: If the Actor does not exist.
+ """
+ if self._server_url is None:
+ response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short')
+ actor = ActorResponse.model_validate(response_to_dict(response)).data
+ if actor.standby_url is None:
+ raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.')
+ self._server_url = actor.standby_url
+ return self._server_url
+
+ async def get_server_openapi_spec(self) -> dict[str, Any] | None:
+ """Return the OpenAPI specification of the Actor's server.
+
+ The specification is the web server schema from the Actor's definition, taken from the build the Actor runs in
+ Server mode: the build set in `actor_standby.build`, or `latest` if none is set. Use it to generate an HTTP
+ client for the server, and point that client at the URL returned by `get_server_url`.
+
+ https://docs.apify.com/platform/actors/development/actor-definition/web-server-schema
+
+ Returns:
+ The OpenAPI specification of the Actor's server, or `None` if the Actor's definition doesn't include one.
+
+ Raises:
+ ApifyClientError: If the Actor is not a Server Actor, or its Server mode build doesn't exist.
+ ApifyApiError: If the Actor does not exist.
+ """
+ response = await self._http_client.call(url=self._build_url(), method='GET', timeout='short')
+ actor = ActorResponse.model_validate(response_to_dict(response)).data
+ if actor.standby_url is None:
+ raise ApifyClientError(f'Actor {self.resource_id} is not a Server Actor.')
+
+ build_ref = (actor.actor_standby.build if actor.actor_standby else None) or 'latest'
+
+ build_id = None
+ tagged_build = (actor.tagged_builds or {}).get(build_ref)
+ if tagged_build is not None:
+ build_id = tagged_build.build_id
+ else:
+ # Not a tag, so look `build_ref` up as a build number, which the API can't resolve directly.
+ async for build_item in self.builds().iterate(desc=True):
+ if build_item.build_number == build_ref:
+ build_id = build_item.id
+ break
+
+ build = None
+ if build_id is not None:
+ build_client = self._client_registry.build_client(
+ resource_id=build_id,
+ base_url=self._base_url,
+ public_base_url=self._public_base_url,
+ http_client=self._http_client,
+ client_registry=self._client_registry,
+ )
+ build = await build_client.get()
+ if build is None:
+ raise ApifyClientError(f'Build {build_ref} of Actor {self.resource_id} does not exist.')
+
+ if build.actor_definition is None:
+ return None
+ return build.actor_definition.web_server_schema
+
+ async def server_request(
+ self,
+ path: str = '/',
+ *,
+ method: HttpMethod = 'GET',
+ params: Mapping[str, str | int | float | bool | datetime | list[str] | None] | None = None,
+ headers: dict[str, str] | None = None,
+ data: str | bytes | None = None,
+ json: JsonSerializable | None = None,
+ compression: HttpCompressionAlgorithm | HttpCompressor | None = None,
+ timeout: Timeout = 'long',
+ ) -> HttpResponse:
+ """Send an HTTP request to the Actor's server.
+
+ A Server Actor runs a web server that is called over HTTP, without starting a run for each call. The request
+ goes to the URL returned by `get_server_url` and carries the client's token, which the platform consumes, so it
+ never reaches the Actor's code. The first request to an idle Actor waits for the platform to start a server
+ run, which is why the timeout defaults to `long`.
+
+ A server error or a rate-limit response is retried like an API call, so the server may receive the same
+ request more than once. Request bodies are sent uncompressed unless `compression` is set, since the Actor's
+ server may not decode a compressed body.
+
+ https://docs.apify.com/platform/actors/running/standby
+
+ Args:
+ path: The path on the Actor's server, for example `/search`. It is always resolved against the server URL,
+ so the request, and the token with it, cannot be sent to another host. Pass the query in `params`.
+ method: HTTP method of the request.
+ params: Query parameters of the request, converted the same way as for API calls: booleans become `true`
+ or `false`, a list of strings becomes one comma-separated value, a datetime becomes an ISO 8601 string
+ in UTC, and `None` values are left out.
+ headers: Additional headers of the request.
+ data: Raw request body. Cannot be used together with `json`.
+ json: JSON-serializable request body. Cannot be used together with `data`.
+ compression: Compression algorithm for the request body, as a string literal or an `HttpCompressor`
+ instance. The body is compressed under the same conditions as for API calls. Set it only if the
+ Actor's server decodes the resulting `Content-Encoding`.
+ timeout: Timeout for the HTTP request.
+
+ Returns:
+ The response of the Actor's server.
+
+ Raises:
+ ApifyClientError: If the Actor is not a Server Actor.
+ ApifyApiError: If the Actor does not exist, or the server responds with an error status.
+ ImportError: If `compression` needs an optional extra that is not installed.
+ ValueError: If both `data` and `json` are passed, or `compression` is not a supported algorithm.
+ """
+ server_url = await self.get_server_url()
+ return await self._http_client.call(
+ url=f'{server_url.rstrip("/")}/{path.lstrip("/")}',
+ method=method,
+ params=dict(params) if params is not None else None,
+ headers=headers,
+ data=data,
+ json=json,
+ timeout=timeout,
+ compression=resolve_compressor(compression) if compression is not None else False,
+ )
diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py
index b06d542c..b8cccebe 100644
--- a/src/apify_client/http_clients/_base.py
+++ b/src/apify_client/http_clients/_base.py
@@ -312,6 +312,7 @@ def _prepare_request_call(
params: dict[str, Any] | None = None,
data: str | bytes | bytearray | StreamedBodySource | None = None,
json: JsonSerializable | None = None,
+ compression: HttpCompressor | bool = True,
) -> tuple[dict[str, str], dict[str, Any] | None, bytes | StreamedRequestBody | None]:
"""Prepare headers, params, and body for an HTTP request.
@@ -319,11 +320,11 @@ def _prepare_request_call(
JSON body. Header names are treated case-insensitively and per-request values win over the client
defaults. For JSON bodies, a `Content-Type` header is set unless the caller supplied one.
- The body is compressed unless a `Content-Encoding` header is already set, the body is smaller than
+ The body is compressed with the client's compressor, or with the one passed in `compression`, unless
+ `compression` is `False`, a `Content-Encoding` header is already set, the body is smaller than
`MIN_COMPRESSION_SIZE`, or its content type says the payload is already compressed. A caller-supplied
`Content-Encoding` is forwarded verbatim, which is how a pre-encoded body is uploaded - including one in
- an encoding the client ships no compressor for. `Content-Encoding: identity` therefore opts a single
- request out of compression.
+ an encoding the client ships no compressor for.
A body streamed from a file-like object, an iterable of byte chunks, or a streamed response is wrapped in
`StreamedRequestBody` and never compressed, since its chunks go out as they are produced. A caller-supplied
@@ -351,15 +352,18 @@ def _prepare_request_call(
if isinstance(data, (str, bytes, bytearray)):
content = data.encode('utf-8') if isinstance(data, str) else bytes(data)
+ compressor = self._http_compressor if compression is True else compression
+
# A caller-supplied encoding says the body arrives already encoded, so compressing it here would
# both mislabel it and waste the work.
if (
- self._get_header(headers, 'content-encoding') is None
+ compressor is not False
+ and self._get_header(headers, 'content-encoding') is None
and len(content) >= MIN_COMPRESSION_SIZE
and is_compressible_content_type(self._get_header(headers, 'content-type'))
):
- content = self._http_compressor.compress(content)
- headers = self._merge_headers(headers, {'Content-Encoding': self._http_compressor.content_encoding})
+ content = compressor.compress(content)
+ headers = self._merge_headers(headers, {'Content-Encoding': compressor.content_encoding})
elif data is not None:
# Without this the request would go out with no body at all, losing the payload without a word.
@@ -483,6 +487,9 @@ class HttpClient(HttpClientBase):
The client's default headers from `self._headers` have to go out with every request, otherwise the
`Authorization` header never reaches the API. The inherited `call` merges them into the per-request headers
through `_prepare_request_call`, so only a client that replaces `call` has to do it itself.
+
+ A replaced `call` has to accept every argument of the inherited one, including `compression`, which
+ `ActorClient.server_request` always passes, as `False` unless its caller asks for compression.
"""
def __enter__(self) -> Self:
@@ -509,6 +516,7 @@ def call(
json: JsonSerializable | None = None,
stream: bool | None = None,
timeout: Timeout = 'medium',
+ compression: HttpCompressor | bool = True,
) -> HttpResponse:
"""Make an HTTP request with automatic retry and exponential backoff.
@@ -526,6 +534,8 @@ def call(
timeout: Timeout for the API HTTP request. Use `short`, `medium`, or `long` tier literals for
preconfigured timeouts. A `timedelta` overrides it for this call (capped at `timeout_max`), and
`no_timeout` disables the timeout entirely.
+ compression: How to compress the request body. `True` uses the client's compressor, an `HttpCompressor`
+ is used for this request only, and `False` sends the body uncompressed.
Returns:
The HTTP response object.
@@ -545,6 +555,7 @@ def call(
params=params,
data=data,
json=json,
+ compression=compression,
)
return self._retry_with_exp_backoff(
@@ -720,6 +731,7 @@ async def call(
json: JsonSerializable | None = None,
stream: bool | None = None,
timeout: Timeout = 'medium',
+ compression: HttpCompressor | bool = True,
) -> HttpResponse:
"""Make an HTTP request with automatic retry and exponential backoff.
@@ -737,6 +749,8 @@ async def call(
timeout: Timeout for the API HTTP request. Use `short`, `medium`, or `long` tier literals for
preconfigured timeouts. A `timedelta` overrides it for this call (capped at `timeout_max`), and
`no_timeout` disables the timeout entirely.
+ compression: How to compress the request body. `True` uses the client's compressor, an `HttpCompressor`
+ is used for this request only, and `False` sends the body uncompressed.
Returns:
The HTTP response object.
@@ -755,13 +769,14 @@ async def call(
# offload preparation to a worker thread whenever there is something to compress. A body the
# client sends as it is costs less to prepare inline than the hop itself. A `json` body always
# hops, as its size is only known once serialized.
- if json is not None or self._is_body_worth_compressing(data):
+ if json is not None or (compression is not False and self._is_body_worth_compressing(data)):
prepared_headers, prepared_params, content = await asyncio.to_thread(
self._prepare_request_call,
headers=headers,
params=params,
data=data,
json=json,
+ compression=compression,
)
else:
prepared_headers, prepared_params, content = self._prepare_request_call(
@@ -769,6 +784,7 @@ async def call(
params=params,
data=data,
json=json,
+ compression=compression,
)
return await self._retry_with_exp_backoff(
diff --git a/tests/unit/test_actor_server_request.py b/tests/unit/test_actor_server_request.py
new file mode 100644
index 00000000..c9a9b205
--- /dev/null
+++ b/tests/unit/test_actor_server_request.py
@@ -0,0 +1,444 @@
+from __future__ import annotations
+
+import gzip
+import json
+from typing import TYPE_CHECKING, Any
+
+import pytest
+from werkzeug import Response
+
+from apify_client.errors import ApifyClientError, NotFoundError
+from apify_client.http_compressors import GzipHttpCompressor
+
+if TYPE_CHECKING:
+ from pytest_httpserver import HTTPServer
+ from werkzeug import Request
+
+ from apify_client import ApifyClient, ApifyClientAsync
+ from apify_client.http_compressors import HttpCompressor
+ from apify_client.types import HttpCompressionAlgorithm
+
+pytestmark = pytest.mark.usefixtures('http_client_classes')
+
+_MOCKED_ACTOR_ID = 'test_actor_id'
+_ACTOR_PATH = f'/v2/actors/{_MOCKED_ACTOR_ID}'
+_SERVER_PREFIX = '/server'
+
+
+def actor_response(server_url: str | None, **extra_fields: Any) -> dict[str, Any]:
+ return {
+ 'data': {
+ 'id': _MOCKED_ACTOR_ID,
+ 'userId': 'test_user_id',
+ 'name': 'test-actor',
+ 'username': 'test-user',
+ 'isPublic': False,
+ 'createdAt': '2026-08-01T10:00:00.000Z',
+ 'modifiedAt': '2026-08-01T10:00:00.000Z',
+ 'stats': {},
+ 'versions': [],
+ 'defaultRunOptions': {},
+ 'standbyUrl': server_url,
+ **extra_fields,
+ }
+ }
+
+
+def expect_server_actor(httpserver: HTTPServer) -> None:
+ server_url = httpserver.url_for(_SERVER_PREFIX)
+ httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(server_url))
+
+
+def server_requests(httpserver: HTTPServer) -> list[Request]:
+ return [request for request, _ in httpserver.log if request.path.startswith(f'{_SERVER_PREFIX}/')]
+
+
+def test_server_request_sends_request_to_server_url(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """The request goes to the server URL with the method, path, query, body, and the client's token."""
+ expect_server_actor(httpserver)
+ httpserver.expect_request(f'{_SERVER_PREFIX}/search', method='POST').respond_with_json({'ok': True})
+
+ response = sync_client.actor(_MOCKED_ACTOR_ID).server_request(
+ '/search', method='POST', params={'q': 'apify'}, json={'limit': 5}
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {'ok': True}
+ captured = server_requests(httpserver)
+ assert len(captured) == 1
+ assert captured[0].args.to_dict() == {'q': 'apify'}
+ assert json.loads(captured[0].get_data()) == {'limit': 5}
+ assert captured[0].headers['Authorization'] == 'Bearer test'
+
+
+async def test_server_request_sends_request_to_server_url_async(
+ httpserver: HTTPServer, async_client: ApifyClientAsync
+) -> None:
+ """Async variant of `test_server_request_sends_request_to_server_url`."""
+ expect_server_actor(httpserver)
+ httpserver.expect_request(f'{_SERVER_PREFIX}/search', method='POST').respond_with_json({'ok': True})
+
+ response = await async_client.actor(_MOCKED_ACTOR_ID).server_request(
+ '/search', method='POST', params={'q': 'apify'}, json={'limit': 5}
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {'ok': True}
+ captured = server_requests(httpserver)
+ assert len(captured) == 1
+ assert captured[0].args.to_dict() == {'q': 'apify'}
+ assert json.loads(captured[0].get_data()) == {'limit': 5}
+ assert captured[0].headers['Authorization'] == 'Bearer test'
+
+
+def test_server_request_fetches_server_url_once(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """Later calls on the same client reuse the server URL fetched by the first one."""
+ expect_server_actor(httpserver)
+ httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_json({'ok': True})
+ actor_client = sync_client.actor(_MOCKED_ACTOR_ID)
+
+ actor_client.server_request()
+ actor_client.server_request()
+
+ actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH]
+ assert len(actor_gets) == 1
+
+
+async def test_server_request_fetches_server_url_once_async(
+ httpserver: HTTPServer, async_client: ApifyClientAsync
+) -> None:
+ """Async variant of `test_server_request_fetches_server_url_once`."""
+ expect_server_actor(httpserver)
+ httpserver.expect_request(f'{_SERVER_PREFIX}/', method='GET').respond_with_json({'ok': True})
+ actor_client = async_client.actor(_MOCKED_ACTOR_ID)
+
+ await actor_client.server_request()
+ await actor_client.server_request()
+
+ actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH]
+ assert len(actor_gets) == 1
+
+
+def test_get_server_url_returns_standby_url(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """`get_server_url` returns the Actor's `standby_url` and fetches it from the API only once."""
+ expect_server_actor(httpserver)
+ actor_client = sync_client.actor(_MOCKED_ACTOR_ID)
+
+ assert actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX)
+ assert actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX)
+
+ actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH]
+ assert len(actor_gets) == 1
+
+
+async def test_get_server_url_returns_standby_url_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None:
+ """Async variant of `test_get_server_url_returns_standby_url`."""
+ expect_server_actor(httpserver)
+ actor_client = async_client.actor(_MOCKED_ACTOR_ID)
+
+ assert await actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX)
+ assert await actor_client.get_server_url() == httpserver.url_for(_SERVER_PREFIX)
+
+ actor_gets = [request for request, _ in httpserver.log if request.path == _ACTOR_PATH]
+ assert len(actor_gets) == 1
+
+
+def test_get_server_url_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """`get_server_url` raises for an Actor without Server mode enabled."""
+ httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None))
+
+ with pytest.raises(ApifyClientError, match='is not a Server Actor'):
+ sync_client.actor(_MOCKED_ACTOR_ID).get_server_url()
+
+
+@pytest.mark.parametrize(
+ 'path',
+ [
+ pytest.param('//evil.example/steal', id='protocol-relative'),
+ pytest.param('@evil.example/steal', id='userinfo'),
+ pytest.param('https://evil.example/steal', id='absolute url'),
+ ],
+)
+def test_server_request_path_stays_on_server_host(httpserver: HTTPServer, sync_client: ApifyClient, path: str) -> None:
+ """A path that looks like another host is still sent to the server, as a path on it."""
+ expect_server_actor(httpserver)
+ httpserver.no_handler_status_code = 200
+
+ response = sync_client.actor(_MOCKED_ACTOR_ID).server_request(path)
+
+ assert response.status_code == 200
+ assert len(server_requests(httpserver)) == 1
+
+
+def test_server_request_sends_body_uncompressed(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """A body large enough for the client to compress is sent as is, since servers may not decode it."""
+ expect_server_actor(httpserver)
+ httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True})
+ payload = {'items': ['x' * 100] * 100}
+
+ sync_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload)
+
+ captured = server_requests(httpserver)
+ assert len(captured) == 1
+ assert 'Content-Encoding' not in captured[0].headers
+ assert json.loads(captured[0].get_data()) == payload
+
+
+async def test_server_request_sends_body_uncompressed_async(
+ httpserver: HTTPServer, async_client: ApifyClientAsync
+) -> None:
+ """Async variant of `test_server_request_sends_body_uncompressed`."""
+ expect_server_actor(httpserver)
+ httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True})
+ payload = {'items': ['x' * 100] * 100}
+
+ await async_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload)
+
+ captured = server_requests(httpserver)
+ assert len(captured) == 1
+ assert 'Content-Encoding' not in captured[0].headers
+ assert json.loads(captured[0].get_data()) == payload
+
+
+@pytest.mark.parametrize(
+ 'compression',
+ [
+ pytest.param('gzip', id='algorithm name'),
+ pytest.param(GzipHttpCompressor(quality=1), id='compressor instance'),
+ ],
+)
+def test_server_request_compresses_body_when_asked(
+ httpserver: HTTPServer, sync_client: ApifyClient, compression: HttpCompressionAlgorithm | HttpCompressor
+) -> None:
+ """With `compression` set, a body large enough to compress is sent compressed and labeled with its encoding."""
+ expect_server_actor(httpserver)
+ httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True})
+ payload = {'items': ['x' * 100] * 100}
+
+ sync_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload, compression=compression)
+
+ captured = server_requests(httpserver)
+ assert len(captured) == 1
+ assert captured[0].headers.get('Content-Encoding') == 'gzip'
+ assert json.loads(gzip.decompress(captured[0].get_data())) == payload
+
+
+async def test_server_request_compresses_body_when_asked_async(
+ httpserver: HTTPServer, async_client: ApifyClientAsync
+) -> None:
+ """Async variant of `test_server_request_compresses_body_when_asked`."""
+ expect_server_actor(httpserver)
+ httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True})
+ payload = {'items': ['x' * 100] * 100}
+
+ await async_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', json=payload, compression='gzip')
+
+ captured = server_requests(httpserver)
+ assert len(captured) == 1
+ assert captured[0].headers.get('Content-Encoding') == 'gzip'
+ assert json.loads(gzip.decompress(captured[0].get_data())) == payload
+
+
+def test_server_request_rejects_unknown_compression(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """An unsupported compression algorithm raises a `ValueError` without sending a request to the server."""
+ expect_server_actor(httpserver)
+
+ with pytest.raises(ValueError, match='Unsupported compression algorithm'):
+ sync_client.actor(_MOCKED_ACTOR_ID).server_request('/items', method='POST', data='x', compression='zip') # ty: ignore[invalid-argument-type]
+
+ assert server_requests(httpserver) == []
+
+
+def test_server_request_keeps_caller_content_encoding(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """A `Content-Encoding` header passed by the caller is sent as given, with the body untouched."""
+ expect_server_actor(httpserver)
+ httpserver.expect_request(f'{_SERVER_PREFIX}/items', method='POST').respond_with_json({'ok': True})
+ body = gzip.compress(b'x' * 2000)
+
+ sync_client.actor(_MOCKED_ACTOR_ID).server_request(
+ '/items', method='POST', headers={'content-encoding': 'gzip'}, data=body
+ )
+
+ captured = server_requests(httpserver)
+ assert len(captured) == 1
+ assert captured[0].headers.get('Content-Encoding') == 'gzip'
+ assert captured[0].get_data() == body
+
+
+def test_server_request_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """An Actor without Server mode enabled raises an error before any request to a server."""
+ httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None))
+
+ with pytest.raises(ApifyClientError, match='is not a Server Actor'):
+ sync_client.actor(_MOCKED_ACTOR_ID).server_request()
+
+
+async def test_server_request_without_server_mode_raises_async(
+ httpserver: HTTPServer, async_client: ApifyClientAsync
+) -> None:
+ """Async variant of `test_server_request_without_server_mode_raises`."""
+ httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None))
+
+ with pytest.raises(ApifyClientError, match='is not a Server Actor'):
+ await async_client.actor(_MOCKED_ACTOR_ID).server_request()
+
+
+def test_server_request_missing_actor_raises_not_found(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """A missing Actor raises `NotFoundError`."""
+ httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(
+ {'error': {'type': 'record-not-found', 'message': 'Actor was not found'}}, status=404
+ )
+
+ with pytest.raises(NotFoundError):
+ sync_client.actor(_MOCKED_ACTOR_ID).server_request()
+
+
+def test_server_request_error_status_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """An error status from the server raises the matching `ApifyApiError` subclass."""
+ expect_server_actor(httpserver)
+ httpserver.expect_request(f'{_SERVER_PREFIX}/missing', method='GET').respond_with_data('Not here', status=404)
+
+ with pytest.raises(NotFoundError, match='Not here'):
+ sync_client.actor(_MOCKED_ACTOR_ID).server_request('/missing')
+
+
+_OPENAPI_SPEC = {'openapi': '3.0.0', 'info': {'title': 'Test server', 'version': '1.0.0'}, 'paths': {}}
+_TAGGED_BUILDS = {
+ 'latest': {'buildId': 'latest_build_id', 'buildNumber': '0.0.2'},
+ 'beta': {'buildId': 'beta_build_id', 'buildNumber': '0.0.3'},
+}
+
+
+def build_response(build_id: str, actor_definition: dict[str, Any] | None) -> dict[str, Any]:
+ return {
+ 'data': {
+ 'id': build_id,
+ 'actId': _MOCKED_ACTOR_ID,
+ 'userId': 'test_user_id',
+ 'startedAt': '2026-08-01T10:00:00.000Z',
+ 'status': 'SUCCEEDED',
+ 'meta': {'origin': 'API'},
+ 'buildNumber': '0.0.1',
+ 'actorDefinition': actor_definition,
+ }
+ }
+
+
+def expect_builds_list(httpserver: HTTPServer, builds: dict[str, str]) -> None:
+ """Serve the Actor's builds list as one page, so a request for a later offset gets no items."""
+ items = [
+ {
+ 'id': build_id,
+ 'status': 'SUCCEEDED',
+ 'startedAt': '2026-08-01T10:00:00.000Z',
+ 'usageTotalUsd': 0.01,
+ 'buildNumber': build_number,
+ }
+ for build_id, build_number in builds.items()
+ ]
+
+ def handler(request: Request) -> Response:
+ offset = int(request.args.get('offset', 0))
+ page = items[offset:]
+ data = {'total': len(items), 'offset': offset, 'limit': 1000, 'desc': True, 'count': len(page), 'items': page}
+ return Response(json.dumps({'data': data}), content_type='application/json')
+
+ httpserver.expect_request(f'{_ACTOR_PATH}/builds', method='GET').respond_with_handler(handler)
+
+
+def expect_server_actor_with_build(
+ httpserver: HTTPServer, *, standby_build: str | None, build_id: str, actor_definition: dict[str, Any] | None
+) -> None:
+ actor_standby = {'isEnabled': True, 'build': standby_build}
+ httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(
+ actor_response(httpserver.url_for(_SERVER_PREFIX), taggedBuilds=_TAGGED_BUILDS, actorStandby=actor_standby)
+ )
+ httpserver.expect_request(f'/v2/actor-builds/{build_id}', method='GET').respond_with_json(
+ build_response(build_id, actor_definition)
+ )
+
+
+@pytest.mark.parametrize(
+ ('standby_build', 'build_id'),
+ [
+ pytest.param(None, 'latest_build_id', id='no build set uses latest'),
+ pytest.param('beta', 'beta_build_id', id='build tag'),
+ ],
+)
+def test_get_server_openapi_spec_from_tagged_build(
+ httpserver: HTTPServer, sync_client: ApifyClient, standby_build: str | None, build_id: str
+) -> None:
+ """The specification comes from the web server schema of the build tagged for Server mode."""
+ expect_server_actor_with_build(
+ httpserver, standby_build=standby_build, build_id=build_id, actor_definition={'webServerSchema': _OPENAPI_SPEC}
+ )
+
+ assert sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC
+
+
+async def test_get_server_openapi_spec_from_tagged_build_async(
+ httpserver: HTTPServer, async_client: ApifyClientAsync
+) -> None:
+ """Async variant of `test_get_server_openapi_spec_from_tagged_build`."""
+ expect_server_actor_with_build(
+ httpserver, standby_build='beta', build_id='beta_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC}
+ )
+
+ assert await async_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC
+
+
+def test_get_server_openapi_spec_from_build_number(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """A build number set for Server mode is looked up in the Actor's builds."""
+ expect_server_actor_with_build(
+ httpserver, standby_build='0.0.1', build_id='old_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC}
+ )
+ expect_builds_list(httpserver, {'latest_build_id': '0.0.2', 'old_build_id': '0.0.1'})
+
+ assert sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC
+
+
+async def test_get_server_openapi_spec_from_build_number_async(
+ httpserver: HTTPServer, async_client: ApifyClientAsync
+) -> None:
+ """Async variant of `test_get_server_openapi_spec_from_build_number`."""
+ expect_server_actor_with_build(
+ httpserver, standby_build='0.0.1', build_id='old_build_id', actor_definition={'webServerSchema': _OPENAPI_SPEC}
+ )
+ expect_builds_list(httpserver, {'latest_build_id': '0.0.2', 'old_build_id': '0.0.1'})
+
+ assert await async_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() == _OPENAPI_SPEC
+
+
+@pytest.mark.parametrize(
+ 'actor_definition',
+ [
+ pytest.param({'name': 'test-actor'}, id='no web server schema'),
+ pytest.param(None, id='no actor definition'),
+ ],
+)
+def test_get_server_openapi_spec_without_schema_returns_none(
+ httpserver: HTTPServer, sync_client: ApifyClient, actor_definition: dict[str, Any] | None
+) -> None:
+ """A build without a web server schema has no specification to return."""
+ expect_server_actor_with_build(
+ httpserver, standby_build=None, build_id='latest_build_id', actor_definition=actor_definition
+ )
+
+ assert sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec() is None
+
+
+def test_get_server_openapi_spec_unknown_build_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """A Server mode build that is neither a tag nor a listed build number raises an error."""
+ expect_server_actor_with_build(httpserver, standby_build='9.9.9', build_id='unused_build_id', actor_definition=None)
+ expect_builds_list(httpserver, {'latest_build_id': '0.0.2'})
+
+ with pytest.raises(ApifyClientError, match='does not exist'):
+ sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec()
+
+
+def test_get_server_openapi_spec_without_server_mode_raises(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
+ """`get_server_openapi_spec` raises for an Actor without Server mode enabled."""
+ httpserver.expect_request(_ACTOR_PATH, method='GET').respond_with_json(actor_response(None))
+
+ with pytest.raises(ApifyClientError, match='is not a Server Actor'):
+ sync_client.actor(_MOCKED_ACTOR_ID).get_server_openapi_spec()
diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py
index 00d07b9a..778651a5 100644
--- a/tests/unit/test_http_clients.py
+++ b/tests/unit/test_http_clients.py
@@ -783,6 +783,28 @@ def test_prepare_request_call_skips_compression_below_threshold(compressor_case:
assert not any(key.lower() == 'content-encoding' for key in headers)
+def test_prepare_request_call_skips_compression_when_disabled() -> None:
+ """With `compression=False`, a body above the threshold is sent verbatim with no `Content-Encoding`."""
+ client = ConcreteHttpClient()
+ body = b'x' * MIN_COMPRESSION_SIZE
+
+ headers, _params, data = client._prepare_request_call(data=body, compression=False)
+
+ assert data == body
+ assert not any(key.lower() == 'content-encoding' for key in headers)
+
+
+def test_prepare_request_call_uses_per_request_compressor() -> None:
+ """A compressor passed in `compression` is used for the request in place of the client's compressor."""
+ client = ConcreteHttpClient(http_compressor=GzipHttpCompressor())
+ body = b'x' * MIN_COMPRESSION_SIZE
+
+ headers, _params, data = client._prepare_request_call(data=body, compression=BrotliHttpCompressor())
+
+ assert headers['Content-Encoding'] == 'br'
+ assert brotli.decompress(data) == body
+
+
def test_prepare_request_call_compresses_bytearray_data(compressor_case: tuple) -> None:
"""A `bytearray` body above the threshold is compressed without error (regression: needs bytes conversion)."""
compressor, content_encoding, decompress = compressor_case
@@ -1219,6 +1241,22 @@ async def test_async_call_skips_thread_offload_for_a_body_it_cannot_compress(
spy.assert_not_called()
+async def test_async_call_skips_thread_offload_when_compression_is_disabled(
+ http_client_async_class: type[HttpClientAsync],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A raw body is prepared inline when compression is disabled, whatever its size."""
+ client = http_client_async_class(token='test_token')
+ monkeypatch.setattr(client, 'send_request', AsyncMock(return_value=Mock(status_code=200)))
+ spy = _to_thread_spy(monkeypatch)
+
+ await client.call(
+ method='PUT', url='https://api.test.com/endpoint', data=b'x' * MIN_COMPRESSION_SIZE, compression=False
+ )
+
+ spy.assert_not_called()
+
+
class StreamingTransport(HttpClient):
"""A transport that drains every body it is handed and answers from a script of statuses and exceptions."""
diff --git a/tests/unit/test_pluggable_http_client.py b/tests/unit/test_pluggable_http_client.py
index edbc6c0a..75387db4 100644
--- a/tests/unit/test_pluggable_http_client.py
+++ b/tests/unit/test_pluggable_http_client.py
@@ -35,6 +35,7 @@
from pytest_httpserver import HTTPServer
+ from apify_client.http_compressors import HttpCompressor
from apify_client.types import StreamedBodySource, Timeout
@@ -99,6 +100,7 @@ def call(
json: Any = None,
stream: bool | None = None,
timeout: Timeout = 'medium',
+ compression: HttpCompressor | bool = True,
) -> HttpResponse:
self.calls.append(
{
@@ -110,6 +112,7 @@ def call(
'json': json,
'stream': stream,
'timeout': timeout,
+ 'compression': compression,
}
)
return _make_fake_response()
@@ -133,6 +136,7 @@ async def call(
json: Any = None,
stream: bool | None = None,
timeout: Timeout = 'medium',
+ compression: HttpCompressor | bool = True,
) -> HttpResponse:
self.calls.append(
{
@@ -144,6 +148,7 @@ async def call(
'json': json,
'stream': stream,
'timeout': timeout,
+ 'compression': compression,
}
)
return _make_fake_response()