Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ indent_size = 2

[*.{yml,yaml,json,md}]
indent_size = 2

[tests/**.cs]
# CA1861: tests state their input data inline, as literal arrays at the call site; hoisting each into a static
# field would separate the data from the assertion that depends on it, and test code has no hot path where the
# repeated allocation matters.
dotnet_diagnostic.CA1861.severity = none
9 changes: 5 additions & 4 deletions .github/workflows/dotnet-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ jobs:
- name: Build (warnings as errors)
run: dotnet build --configuration Release --no-restore

# Offline unit tests (mock transport): prove the retry/error/signature logic without the API.
# Offline unit tests (mock transport): prove the retry/error/signature logic without the API. Every test step
# sets TreatNoTestsAsError, so a filter that matches no tests fails the workflow instead of passing.
- name: Unit tests
run: dotnet test --configuration Release --no-build --filter Category=Unit
run: dotnet test --configuration Release --no-build --filter Category=Unit -- RunConfiguration.TreatNoTestsAsError=true

# Fail fast if the integration-test secret is missing or empty. Without this guard the
# integration tests silently skip (they self-skip when APIFY_TOKEN is unset), so a green run
Expand All @@ -70,11 +71,11 @@ jobs:
env:
# The integration-test token is stored as a repository secret.
APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }}
run: dotnet test --configuration Release --no-build --filter Category=Integration
run: dotnet test --configuration Release --no-build --filter Category=Integration -- RunConfiguration.TreatNoTestsAsError=true

# Standalone CI step that verifies the documentation examples: the Examples suite runs each example
# end-to-end against the live API and compiles every C# snippet in the README and docs/.
- name: Test examples
env:
APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }}
run: dotnet test --configuration Release --no-build --filter Category=Examples
run: dotnet test --configuration Release --no-build --filter Category=Examples -- RunConfiguration.TreatNoTestsAsError=true
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Changelog

## 0.8.0

- `PushItemsAsync` no longer retries a request that timed out, because the API may already have stored the
items and a retry would store them twice. It also serializes the items once, without building a document
tree.
- `ApifyClientOptions.PublicBaseUrl` now defaults to `https://api.apify.com` instead of `BaseUrl`, as in
the reference client.
- Added `ApifyClient.Stats` (`Statistics`: `Calls`, `Requests`, `RateLimitErrors`).
- Added `ApifyClientOptions.TimeoutLongSecs`, the base of the `Long` timeout tier (default `TimeoutSecs`).
`TimeoutSecs` stays the overall cap, the reference client's `timeoutMaxSecs`.
- Added `StreamedLog.DefaultConsole`: pass it as `log` to `CallAsync` to redirect the run log to the console
with the reference client's `<Actor name> runId:<id> -> ` prefix, and `StreamedLog.CreatePrefixedSink` for
a run that is already known. Documented that omitting `log` does not redirect the run log, unlike the
reference client.
- `ApifyClient.UserAgent` reports a `User-Agent` entry of `ApifyClientOptions.Headers`.
- `GzipHttpCompressor` throws `ArgumentOutOfRangeException` for an undefined `CompressionLevel`.
- Invalid record keys, request IDs and resource IDs now report the caller's parameter name in the
`ArgumentException`.
- A batch add with unprocessed requests counts as a rate limit error in `ApifyClient.Stats`, as in the
reference client.
- Bumped `ApifyClientVersion.ApiSpecVersion` to the Apify OpenAPI spec `v2-2026-10-29T100357Z` and
the project version to `0.8.0`.
- Regenerated the embedded response schemas; `Schedule.uiState` accepts `null`, which the API returns.
- Added typed accessors: `Build.Changelog`/`ExitCode`/`GitBranchName`/`GitCommitId`,
`ActorRun.ConsoleUrl`/`Standby` (the Server Actor deployment), `Title` on `Dataset`/`KeyValueStore`/`RequestQueue`/`Webhook`/`Schedule`,
`Webhook.IsEnabled`/`IsApifyIntegration`, `Schedule.Description`/`UiState`, and
`RequestQueueRequest.LockByClient`/`HandledAt`.
- Added `RunListOptions.Deployment` (filters Standby runs) and `StoreListOptions.IncludeInputSchema`.
- `UserClient.MonthlyUsageAsync`, `LimitsAsync` and `UpdateLimitsAsync` now also work on `User(id)`.
- Resource ids, record keys and request IDs are percent-encoded; every `/` of a resource id becomes `~`, and
an empty value or a `.`/`..` segment throws `ArgumentException`.
- `UpdateRequestAsync` throws `ArgumentException` for a request without an `Id`.
- `BatchAddRequestsAsync` serializes each request once and rejects an oversized request before sending
any batch.
- `PushItemsAsync` throws `ArgumentException` for anything but an object or an array of objects, and sends
a `string` as serialized JSON.
- Added `ApifyClientOptions.Headers`, headers sent with every request.
- Added `ApifyClientOptions.Compressor`, `HttpCompressor`, `BrotliHttpCompressor` (quality 0 to 11) and
`GzipHttpCompressor` for a custom compression quality or algorithm.
- Documented that `GetRecordAsync` never parses a record body.
- The integration tests wait up to 90 seconds for a new resource to appear in a listing.

## 0.7.1

- Bumped `ApifyClientVersion.ApiSpecVersion` to the Apify OpenAPI spec `v2-2026-10-26T112451Z` and
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,16 @@ dotnet add package Apify.Client

## Quick start

`ImplicitUsings` is disabled in this repository, so every `using` (even `System`) is listed explicitly.
The snippets list every `using` explicitly; a project with `ImplicitUsings` enabled can omit `using System;`.

```csharp
using System;
using Apify.Client;

var client = new ApifyClient("my-api-token");

// Start an Actor and wait for it to finish (null waits indefinitely; pass seconds to bound the wait).
var run = await client.Actor("apify/hello-world").CallAsync(null, null, null);
// Start an Actor and wait for it to finish (pass `waitSecs` to bound the wait).
var run = await client.Actor("apify/hello-world").CallAsync();

// Read items from the run's default dataset.
var items = await client.Dataset(run.DefaultDatasetId!).ListItemsAsync();
Expand Down
49 changes: 42 additions & 7 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ using Apify.Client;

var client = new ApifyClient("my-api-token");

// Start an Actor and wait for it to finish. The last argument is the wait budget in seconds;
// pass a value (e.g. 120) to bound the wait, or null to wait indefinitely (as here).
var run = await client.Actor("apify/hello-world").CallAsync(null, null, null);
// Start an Actor and wait for it to finish. Pass waitSecs (e.g. 120) to bound the wait;
// by default it waits indefinitely.
var run = await client.Actor("apify/hello-world").CallAsync();

// Read items from the run's default dataset.
var items = await client.Dataset(run.DefaultDatasetId!).ListItemsAsync();
Expand Down Expand Up @@ -112,27 +112,36 @@ var configured = new ApifyClient(new ApifyClientOptions
|---|---|---|
| `Token` | `null` | API token, sent as a Bearer token. |
| `BaseUrl` | `https://api.apify.com` | API base URL, with or without the `/v2` version path — it's appended only when not already present. |
| `PublicBaseUrl` | `BaseUrl` | Base URL used when building public, shareable resource URLs. Also accepted with or without `/v2`. |
| `PublicBaseUrl` | `https://api.apify.com` | Base URL used when building public, shareable resource URLs, whatever `BaseUrl` is (as in the reference client). Also accepted with or without `/v2`. |
| `MaxRetries` | `8` | Maximum retries for failed requests. |
| `MinDelayBetweenRetriesMillis` | `500` | Minimum delay between retries (exponential backoff). |
| `MaxDelayBetweenRetriesMillis` | request timeout | Upper bound on the growing inter-retry delay. |
| `TimeoutSecs` | `360` | Base duration of the `Long` timeout tier (downloads, uploads, streaming); also the overall per-request cap every attempt's growing timeout is clamped to, regardless of tier. |
| `TimeoutSecs` | `360` | The overall per-request cap (the reference client's `timeoutMaxSecs`): every attempt's growing timeout, including a `WithTimeout` override, is clamped to it, regardless of tier. Also the base of the `Long` tier unless `TimeoutLongSecs` is set. |
| `TimeoutLongSecs` | `TimeoutSecs` | Base duration of the `Long` timeout tier: downloads, uploads, streaming. Matches the reference client's `timeoutLongSecs`. |
| `TimeoutShortSecs` | `5` | Base duration of the `Short` timeout tier: simple metadata reads/writes (get/update/delete a resource). |
| `TimeoutMediumSecs` | `30` | Base duration of the `Medium` timeout tier: listing, batch and trigger calls. |
| `UserAgentSuffix` | `null` | Custom suffix appended to the `User-Agent` header. |
| `RequestCompression` | `RequestCompression.Brotli` | Algorithm used to compress request bodies ≥ 1024 bytes: `Brotli` (`Content-Encoding: br`) or `Gzip` (`Content-Encoding: gzip`). |
| `Compressor` | `null` | A custom `HttpCompressor` for a non-default compression quality or another algorithm; takes precedence over `RequestCompression`. |
| `Headers` | `null` | Additional headers sent with every request, such as an integration platform marker. They win over the built-in `User-Agent` and `Authorization`; header names are case-insensitive, and `client.UserAgent` reports a `User-Agent` override. |
| `HttpTransport` | `HttpClientTransport` | The replaceable transport (`Apify.Client.Http.IHttpTransport`). |

Requests are retried on network errors, HTTP 429 (rate limit) and 5xx responses, with exponential
backoff and jitter. 4xx responses (other than 429) are thrown immediately as `ApifyApiException`.

### Request statistics

`client.Stats` counts what the client has done, as the reference client's `stats` does: `Calls` (client calls),
`Requests` (HTTP requests, including retries) and `RateLimitErrors` (a snapshot list of the `429` answers per
attempt: index 0 holds the first-attempt errors, index 1 the first retry's, and so on).

### Timeout tiers

Each method internally picks the timeout tier that fits the expected duration of its request — `Short`
for metadata reads and writes, `Medium` for listing/batch/trigger calls, `Long` for downloads, uploads
and streaming — so the default wait before giving up is consistent across the client instead of a
single one-size-fits-all budget. The three tiers' durations are configured once, on the options above
(`TimeoutShortSecs`/`TimeoutMediumSecs`/`TimeoutSecs`).
(`TimeoutShortSecs`/`TimeoutMediumSecs`/`TimeoutLongSecs`); `TimeoutSecs` is the cap that none of them can exceed.

Every resource client (`client.Actor(id)`, `client.Dataset(id)`, `client.Actors()`, and so on — the
return value of every accessor on `ApifyClient`) exposes `WithTimeout(TimeSpan timeout)`, a general
Expand All @@ -146,9 +155,35 @@ itself for a longer per-call timeout; a shorter override still narrows the cap,
TimeoutSecs = ... })`) and `SetRecordAsync` (via `SetRecordOptions.TimeoutSecs`) already had their own
equivalent, narrower overrides before `WithTimeout` existed and keep them instead.

Request bodies of at least 1024 bytes are compressed before sending. Brotli is used by default; set
### Request body compression

Request bodies of at least 1024 bytes are compressed before sending, unless their content type already
carries its own compression (images, audio, video, archives). Brotli is used by default; set
`RequestCompression = RequestCompression.Gzip` to send gzip-compressed bodies instead.

For a different quality, pass a compressor: `BrotliHttpCompressor` takes a quality from 0 (the fastest) to 11
(the best compression, default 6) and `GzipHttpCompressor` a `System.IO.Compression.CompressionLevel`
(`Fastest`, `Optimal` or `SmallestSize`; default `Optimal`). Derive from `HttpCompressor` to use another
algorithm the API accepts (`deflate`, `identity`): set `ContentEncoding` and implement `CompressAsync`.

```csharp
using Apify.Client;
using Apify.Client.Http;

var client = new ApifyClient(new ApifyClientOptions
{
Token = "<TOKEN>",
Compressor = new BrotliHttpCompressor(quality: 11),
});
```

### Default headers

`ApifyClientOptions.Headers` adds headers to every request, for example an integration platform marker. They
replace the built-in `User-Agent` and `Authorization` headers when named the same, compared
case-insensitively. Only request headers are allowed; a content header such as `Content-Type` makes the
`ApifyClient` constructor throw, because the client sets those per request.

### Replaceable HTTP transport

The transport is `Apify.Client.Http.IHttpTransport`. The default is `HttpClientTransport`, which wraps
Expand Down
4 changes: 3 additions & 1 deletion docs/actors.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ foreach (var actor in page.Items)
- `StartAsync(object? input = null, ActorStartOptions? options = null)` → `ActorRun` (returns immediately).
- `CallAsync(object? input = null, ActorStartOptions? options = null, int? waitSecs = null, Action<string>? log = null)`
→ `ActorRun` (starts then waits; `waitSecs` bounds the wait, `null` waits indefinitely; `log`, if set,
redirects the run's live log to that sink for the duration of the wait). Time spent retrying
redirects the run's live log to that sink for the duration of the wait; unlike the reference client,
omitting it does not redirect the log, so pass `StreamedLog.DefaultConsole` to get the reference's
default redirection to the console, with its `<Actor name> runId:<id> -> ` prefix). Time spent retrying
`ActorStartOptions.WaitForResources` does not count toward `waitSecs`.
- `ValidateInputAsync(object? input = null, ValidateInputOptions? options = null)` → `bool`.

Expand Down
14 changes: 10 additions & 4 deletions docs/misc.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Browse public Actors in the [Apify Store](https://apify.com/store).
| `PricingModel` | `string?` | Filter by pricing model (`FREE`, `PRICE_PER_DATASET_ITEM`, `PAY_PER_EVENT`; the rental model `FLAT_PRICE_PER_MONTH` is no longer supported). |
| `IncludeUnrunnableActors` | `bool?` | Include Actors that cannot currently be run. |
| `AllowsAgenticUsers` | `bool?` | Only Actors that permit agentic (automated) users. |
| `IncludeInputSchema` | `bool?` | Include each Actor's compact `inputSchema` (read it with `Get("inputSchema")`). The API then caps `limit` at 10, so iteration requests pages of at most 10. |
| `ResponseFormat` | `string?` | Requested response format. |

```csharp
Expand All @@ -43,9 +44,14 @@ await foreach (var item in client.Store().IterateAsync(new StoreListOptions { Se

- `GetAsync()` → `User?`. For `Me()` the raw payload includes private account details
(`ToJsonObject()`); for `User(id)` it returns the public profile.
- `MonthlyUsageAsync(string? date = null)` → `JsonObject` (only for `Me()`; `date` is `YYYY-MM-DD`, and
`null` reports the current month).
- `LimitsAsync()` → `JsonObject` / `UpdateLimitsAsync(object newLimits)` (only for `Me()`).
- `MonthlyUsageAsync(string? date = null)` → `JsonObject` (`date` is `YYYY-MM-DD`, and `null` reports the
current month).
- `LimitsAsync()` → `JsonObject` / `UpdateLimitsAsync(object newLimits)`.

The usage and limits methods work on `Me()` and on `User(id)` with the account's ID or username, but only for
your own account or the organization your API token belongs to. API tokens with limited permissions cannot
use the by-ID form (`Me()` is not affected), and updating an organization's limits also needs the permission to
manage it.

```csharp
using System;
Expand All @@ -56,7 +62,7 @@ var me = await client.Me().GetAsync();
Console.WriteLine(me?.Username);
var usage = await client.Me().MonthlyUsageAsync();

// Any user's public profile by id (no private fields, and no account-only methods).
// Any user's public profile by id (no private fields).
var otherUser = await client.User("some-user-id").GetAsync();
Console.WriteLine(otherUser?.Username);
```
Expand Down
Loading
Loading