Skip to main content
Version: Next

Upgrading to v3

This page summarizes the breaking changes between Apify Python API Client v2.x and v3.0.

Python version support

Support for Python 3.10 has been dropped. The Apify Python API Client v3.x now requires Python 3.11 or later. Make sure your environment is running a compatible version before upgrading.

Fully typed clients

Resource client methods now return Pydantic models instead of plain dictionaries. This provides IDE autocompletion, type checking, and early validation of API responses.

Accessing response fields

Before (v2):

from apify_client import ApifyClient

client = ApifyClient(token='MY-APIFY-TOKEN')

# v2 — methods returned plain dicts
run = client.actor('apify/hello-world').call(run_input={'key': 'value'})
dataset_id = run['defaultDatasetId']
status = run['status']

After (v3):

from apify_client import ApifyClient

client = ApifyClient(token='MY-APIFY-TOKEN')

# v3 — methods return Pydantic models
run = client.actor('apify/hello-world').call(run_input={'key': 'value'})
dataset_id = run.default_dataset_id
status = run.status

All model classes are generated from the Apify OpenAPI specification and live in apify_client._models module. They are configured with extra='allow', so any new fields added to the API in the future are preserved on the model instance. Fields are accessed using their Python snake_case names:

run.default_dataset_id   # ✓ use snake_case attribute names
run.id
run.status

Models also use populate_by_name=True, which means you can use either the Python field name or the camelCase alias when constructing a model:

from apify_client._models_generated import Run

# Both work when constructing models
Run(default_dataset_id='abc') # Python field name
Run(defaultDatasetId='abc') # camelCase API alias

Exceptions

Not every method returns a Pydantic model. Methods whose payloads are user-defined or inherently unstructured still return plain types:

Pydantic models as method parameters

Resource client methods that previously accepted only dictionaries for structured input now also accept Pydantic models. Existing code that passes dictionaries continues to work — this change is additive for callers, but is listed here because method type signatures have changed.

Before (v2):

rq_client.add_request({
'url': 'https://example.com',
'uniqueKey': 'https://example.com',
'method': 'GET',
})

After (v3) — both forms are accepted:

from apify_client._models import RequestInput

# Option 1: dict (still works)
rq_client.add_request({
'url': 'https://example.com',
'uniqueKey': 'https://example.com',
'method': 'GET',
})

# Option 2: Pydantic model (new)
rq_client.add_request(RequestInput(
url='https://example.com',
unique_key='https://example.com',
method='GET',
))

Model input is available on methods such as RequestQueueClient.add_request(), RequestQueueClient.batch_add_requests(), ActorClient.start(), ActorClient.call(), TaskClient.start(), TaskClient.call(), TaskClient.update(), and TaskClient.update_input(), among others. Check the API reference for the complete list.

Pluggable HTTP client architecture

The HTTP layer is now abstracted behind HttpClient and HttpClientAsync base classes. The default implementation based on Impit (ImpitHttpClient / ImpitHttpClientAsync) is unchanged, but you can now replace it with your own.

To use a custom HTTP client, implement the call() method and pass the instance via the ApifyClient.with_custom_http_client() class method:

from apify_client import ApifyClient, HttpClient, HttpResponse, Timeout

class MyHttpClient(HttpClient):
def call(self, *, method, url, headers=None, params=None,
data=None, json=None, stream=None, timeout='medium') -> HttpResponse:
...

client = ApifyClient.with_custom_http_client(
token='MY-APIFY-TOKEN',
http_client=MyHttpClient(),
)

The response must satisfy the HttpResponse protocol (properties: status_code, text, content, headers; methods: json(), read(), close(), iter_bytes()). Many popular libraries like httpx already satisfy this protocol out of the box.

For a full walkthrough and working examples, see the Custom HTTP clients concept page and the Custom HTTP client guide.

Tiered timeout system

Individual API methods now use a tiered timeout instead of a single global timeout. Each method declares a default tier appropriate for its expected latency.

Timeout tiers

TierDefaultTypical use case
short5 sFast CRUD operations (get, update, delete)
medium30 sBatch and list operations, starting runs
long360 sLong-polling, streaming, data retrieval
no_timeoutDisabledBlocking calls like actor.call() that wait for a run to finish

A timeout_max value (default 360 s) caps the exponential growth of timeouts across retries.

Configuring default tiers

You can override the default duration of any tier on the ApifyClient constructor:

from datetime import timedelta

from apify_client import ApifyClient

client = ApifyClient(
token='MY-APIFY-TOKEN',
timeout_short=timedelta(seconds=10),
timeout_medium=timedelta(seconds=60),
timeout_long=timedelta(seconds=600),
timeout_max=timedelta(seconds=600),
)

Per-call override

Every resource client method exposes a timeout parameter. You can pass a tier name or a timedelta for a one-off override:

from datetime import timedelta

# Use the 'long' tier for this specific call
actor = client.actor('apify/hello-world').get(timeout='long')

# Or pass an explicit duration
actor = client.actor('apify/hello-world').get(timeout=timedelta(seconds=120))

Retry behavior

On retries, the timeout doubles with each attempt (exponential backoff) up to timeout_max. For example, with timeout_short=5s and timeout_max=360s: attempt 1 uses 5 s, attempt 2 uses 10 s, attempt 3 uses 20 s, and so on.

Updated default timeout tiers

The default timeout tier assigned to each method on non-storage resource clients has been revised to better match the expected latency of the underlying API endpoint. For example, a simple get() call now defaults to short (5 s), while start() defaults to medium (30 s) and call() defaults to no_timeout.

If your code relied on the previous global timeout behavior, review the timeout tier on the methods you use and adjust via the timeout parameter or by overriding tier defaults on the ApifyClient constructor (see Tiered timeout system above).

Exception subclasses for API errors

ApifyApiError now dispatches to a dedicated subclass based on the HTTP status code of the failed response. Instantiating ApifyApiError directly still works — it returns the most specific subclass for the status — so existing except ApifyApiError handlers are unaffected.

The following subclasses are available:

StatusSubclass
400InvalidRequestError
401UnauthorizedError
403ForbiddenError
404NotFoundError
409ConflictError
429RateLimitError
5xxServerError

You can now branch on error kind without inspecting status_code or type:

from apify_client import ApifyClient
from apify_client.errors import NotFoundError, RateLimitError

client = ApifyClient(token='MY-APIFY-TOKEN')

try:
run = client.run('some-run-id').get()
except NotFoundError:
run = None
except RateLimitError:
...

Behavior change: 404 on ambiguous endpoints now raises NotFoundError

Direct, ID-identified fetches like client.dataset(id).get() or client.run(id).get() continue to swallow 404 into None — a 404 there unambiguously means the named resource does not exist. Similarly, .delete() on an ID-identified client keeps its idempotent behavior (404 is silently swallowed).

For calls where a 404 is ambiguous, the client now propagates NotFoundError instead of returning None / silently succeeding. Three categories of endpoints are affected:

  1. Chained calls that target a default sub-resource without an IDrun.dataset(), run.key_value_store(), run.request_queue(), run.log(). A 404 here could mean the parent run is missing OR the default sub-resource is missing, and the API body does not disambiguate. Applies to both .get() and .delete().
  2. .get() / .get_as_bytes() / .stream() on a chained LogClient — e.g. run.log().get(). Direct client.log(build_or_run_id).get() still returns None on 404.
  3. Singleton sub-resource endpoints fetched via a fixed pathScheduleClient.get_log(), TaskClient.get_input(), DatasetClient.get_statistics(), UserClient.monthly_usage(), UserClient.limits(), WebhookClient.test(). These hit paths like /schedules/{id}/log or /actor-tasks/{id}/input, so a 404 effectively means the parent is missing. Return types moved from T | None to T.
from apify_client import ApifyClient
from apify_client.errors import NotFoundError

client = ApifyClient(token='MY-APIFY-TOKEN')

try:
dataset = client.run('some-run-id').dataset().get()
except NotFoundError:
# Previously this returned `None`; now you must handle it explicitly.
dataset = None

try:
schedule_log = client.schedule('some-schedule-id').get_log()
except NotFoundError:
# `get_log()` previously returned `None` when the schedule was missing; now it raises.
schedule_log = None

Direct .get() also now swallows every 404 regardless of the error.type string in the response body (previously only record-not-found and record-or-token-not-found types were swallowed). If your code needs to distinguish between "resource missing" and "404 with an unexpected type", inspect .type on a caught NotFoundError from a non-.get() call path.

Snake_case sort_by values on actors().list()

The sort_by parameter of ActorCollectionClient.list() and ActorCollectionClientAsync.list() now accepts pythonic snake_case values instead of the raw camelCase values used by the API.

Before (v2):

client.actors().list(sort_by='createdAt')
client.actors().list(sort_by='stats.lastRunStartedAt')

After (v3):

client.actors().list(sort_by='created_at')
client.actors().list(sort_by='last_run_started_at')

The default value also changed from 'createdAt' to 'created_at' (behavior is unchanged). The client translates the snake_case value to the form expected by the API internally.