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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to the Rust Apify API client are documented here. The format
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres
to [Semantic Versioning](https://semver.org/).

## [0.4.2] - 2026-06-29

Synchronized with Apify OpenAPI specification `v2-2026-06-29T142258Z` (previously
`v2-2026-06-25T142310Z`). A full operation- and parameter-level audit of every in-scope endpoint
against the new specification (with the JavaScript reference client as the parity authority for the
exposed surface) found the in-scope typed API surface unchanged: same paths, operations, query/header
parameters, request bodies and response schemas. The recently added pay-per-event `charge` and
`validate-input` operations remain covered and correct. The still-uncovered endpoints
(`run-sync`/`run-sync-get-dataset-items`, `/v2/tools/*`, `/v2/browser-info`, and the keyed `POST`
create variants that duplicate covered `PUT` writes) remain out of scope, matching the reference
client. No public API surface change, so this is a patch release.

### Changed
- `API_SPEC_VERSION` bumped to `v2-2026-06-29T142258Z`.

## [0.4.1] - 2026-06-29

Compliance pass against the updated client requirements (no OpenAPI spec change; still
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "apify-client"
version = "0.4.1"
version = "0.4.2"
authors = ["Apify Technologies <support@apify.com>"]
description = "An official, but experimental, AI-generated and AI-maintained Rust client for the Apify API (https://apify.com)."
license = "Apache-2.0"
Expand Down
8 changes: 5 additions & 3 deletions docs/misc.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,11 @@ let client = ApifyClient::new(std::env::var("APIFY_TOKEN")?);
// Current cycle.
let usage = client.me().monthly_usage().await?;

// The cycle containing a specific day (YYYY-MM-DD).
let march = client.me().monthly_usage_for_date(Some("2026-03-15")).await?;
if let Some(cycle) = march.get("usageCycle") {
// The cycle containing a specific day (YYYY-MM-DD). Derive it from "now" rather than
// hard-coding a date so the lookup always lands on a real cycle.
let day = chrono::Utc::now().format("%Y-%m-%d").to_string();
let dated = client.me().monthly_usage_for_date(Some(&day)).await?;
if let Some(cycle) = dated.get("usageCycle") {
let start = cycle.get("startAt").and_then(|v| v.as_str()).unwrap_or("?");
let end = cycle.get("endAt").and_then(|v| v.as_str()).unwrap_or("?");
println!("cycle {start} .. {end}");
Expand Down
36 changes: 36 additions & 0 deletions docs/storages.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ let scratch = client.datasets().get_or_create(None).await?;
`skip_failed_pages`. `DatasetDownloadOptions` adds `attachment`, `bom`, `delimiter`,
`skip_header_row`, `xml_root`, `xml_row`, `feed_title`, `feed_description`.

`DownloadItemsFormat` (re-exported at the crate root) selects the export format for
`download_items`. Variants: `Json`, `Jsonl`, `Csv`, `Xlsx`, `Xml`, `Rss`, `Html`. The method
returns the raw exported bytes (`Vec<u8>`) — for example, CSV text or the binary XLSX workbook —
which you can write to a file or forward to another service:

```rust,no_run
# use apify_client::{ApifyClient, DownloadItemsFormat};
# async fn run(client: ApifyClient) -> Result<(), Box<dyn std::error::Error>> {
let dataset = client.datasets().get_or_create(None).await?;
let csv: Vec<u8> = client
.dataset(&dataset.id)
.download_items(DownloadItemsFormat::Csv, Default::default())
.await?;
println!("exported {} bytes of CSV", csv.len());
# Ok(())
# }
```

## Key-value stores — `client.key_value_stores()` / `client.key_value_store(id)`

`KeyValueStoreCollectionClient`: `list(options: StorageListOptions)`, `get_or_create(name: Option<&str>)`.
Expand Down Expand Up @@ -121,6 +139,24 @@ let scratch = client.datasets().get_or_create(None).await?;
| `delete_request_lock(id, forefront)` | `&str`, `bool` | `()` | Release a lock. |
| `unlock_requests()` | — | `Value` | Release all this client's locks. |

The `forefront` boolean (on `add_request`, `update_request`, `batch_add_requests`,
`prolong_request_lock`, `delete_request_lock`) controls queue ordering: `true` puts the
request(s) at the **front** of the queue so they are handled before the existing backlog;
`false` (the usual choice) appends them at the **back**.

Some request-queue methods return an untyped `serde_json::Value` because the API responses are
open-ended and most callers do not consume them structurally. Their shapes (read fields with
`value.get("...")`):

- `list_and_lock_head` → an object with `items` (the locked head requests), `limit`,
`queueModifiedAt`, `hadMultipleClients`, and the granted `lockSecs`.
- `batch_add_requests` / `batch_delete_requests` → an object with `processedRequests` and
`unprocessedRequests` arrays.
- `list_requests` → an object with `items` (the page of requests), `count`, `limit`, and
`exclusiveStartId` for cursor continuation.
- `unlock_requests` → an object reporting how many locks were released (`unlockedCount`).
- `get_statistics` (datasets) → per-field statistics keyed by field name.

### `RequestQueueRequest` and request-queue return types

`RequestQueueRequest` (from `apify_client::models`) is the value passed to `add_request` /
Expand Down
13 changes: 6 additions & 7 deletions examples/get_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}

// Usage for the billing cycle that contains a specific `YYYY-MM-DD` date — pass `Some(date)`
// to look up a past cycle, or `None` for the current one.
let past_usage = client
.me()
.monthly_usage_for_date(Some("2026-03-15"))
.await?;
if let Some(cycle) = past_usage.get("usageCycle") {
println!("Usage cycle containing 2026-03-15: {cycle}");
// to look up a particular cycle, or `None` for the current one. We derive the date from the
// current day (rather than hard-coding one) so the lookup always lands on a real cycle.
let date = chrono::Utc::now().format("%Y-%m-%d").to_string();
let dated_usage = client.me().monthly_usage_for_date(Some(&date)).await?;
if let Some(cycle) = dated_usage.get("usageCycle") {
println!("Usage cycle containing {date}: {cycle}");
}

Ok(())
Expand Down
36 changes: 32 additions & 4 deletions examples/run_store_actor.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
//! Run an existing Store Actor, wait for it to finish, and read its default dataset.
//! Discover an existing Actor in the Apify Store, run it, wait for it to finish, and read
//! its default dataset.
//!
//! This example uses the Store API (`client.store()`) to find the actor first, so it really
//! exercises "run an Actor discovered in the Store" rather than hard-coding an Actor ID.
//!
//! Run with: `APIFY_TOKEN=... cargo run --example run_store_actor`

use apify_client::ApifyClient;
use apify_client::{ApifyClient, StoreListOptions};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let token = std::env::var("APIFY_TOKEN").expect("set APIFY_TOKEN");
let client = ApifyClient::new(token);

// Start the public `apify/hello-world` Actor and wait up to 2 minutes for it to finish.
// Discover the public `apify/hello-world` Actor through the Apify Store API. It is free,
// fast, and runs without input, which keeps this example reliable.
let store_page = client
.store()
.list(StoreListOptions {
search: Some("hello world".to_string()),
limit: Some(25),
..Default::default()
})
.await?;

let actor = store_page
.items
.into_iter()
.find(|a| {
a.username.as_deref() == Some("apify") && a.name.as_deref() == Some("hello-world")
})
.expect("apify/hello-world should be discoverable in the Apify Store");
println!(
"Found Store actor {} (\"{}\")",
actor.id,
actor.title.clone().unwrap_or_default()
);

// Run the discovered Actor and wait up to 2 minutes for it to finish.
let run = client
.actor("apify/hello-world")
.actor(&actor.id)
.call::<serde_json::Value>(None, Default::default(), Some(120))
.await?;
println!("Run {} finished with status {:?}", run.id, run.status);
Expand Down
38 changes: 19 additions & 19 deletions src/http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,11 @@ impl HttpClient {
// attempt up to the client's overall timeout budget.
let base_timeout = request.timeout;
let mut delay = self.retry.min_delay_between_retries;
let max_attempts = self.retry.max_retries + 1;
// `saturating_add` so an extreme `max_retries` can't overflow the attempt count.
let max_attempts = self.retry.max_retries.saturating_add(1);

for attempt in 1..=max_attempts {
let mut attempt = 1;
loop {
// Grow per-attempt timeout with each attempt, capped at the overall budget.
let mut attempt_request = request.clone();
attempt_request.timeout = self.attempt_timeout(base_timeout, attempt);
Expand Down Expand Up @@ -274,14 +276,10 @@ impl HttpClient {
// a factor of 2) and is capped at the overall request timeout so a single backoff
// can never exceed the budget the whole request is allowed.
sleep(randomized_delay(delay)).await;
delay = (delay * BACKOFF_FACTOR).min(self.retry.timeout);
// `saturating_mul` mirrors the saturating arithmetic in `attempt_timeout`.
delay = delay.saturating_mul(BACKOFF_FACTOR).min(self.retry.timeout);
attempt += 1;
}

// Unreachable: the loop always returns on its final iteration, but the compiler
// cannot prove `max_attempts >= 1`, so provide a defensive fallback.
Err(ApifyClientError::InvalidResponse(
"request failed without a recorded error".to_string(),
))
}

/// Per-attempt timeout: `min(overall_timeout, base * 2^(attempt-1))`.
Expand Down Expand Up @@ -386,23 +384,25 @@ fn next_jitter() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static STATE: AtomicU64 = AtomicU64::new(0);

// Lazily seed from the clock on first use.
let mut current = STATE.load(Ordering::Relaxed);
if current == 0 {
const GOLDEN_GAMMA: u64 = 0x9E3779B97F4A7C15;

// Lazily seed from the clock on first use. A racing double-seed is harmless: both
// candidate seeds are valid SplitMix64 stream starting points.
if STATE.load(Ordering::Relaxed) == 0 {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E3779B97F4A7C15)
.unwrap_or(GOLDEN_GAMMA)
| 1;
// Ignore the race: if two threads seed simultaneously both produce valid streams.
let _ = STATE.compare_exchange(0, seed, Ordering::Relaxed, Ordering::Relaxed);
current = STATE.load(Ordering::Relaxed);
}

// SplitMix64 step, advancing the shared state atomically.
let next = current.wrapping_add(0x9E3779B97F4A7C15);
STATE.store(next, Ordering::Relaxed);
let mut z = next;
// SplitMix64: advance the shared state by the golden-ratio increment in a single atomic
// read-modify-write (`fetch_add`) so concurrent callers each observe a distinct value —
// a plain load-then-store could hand two racing retries the same number. Then scramble.
let mut z = STATE
.fetch_add(GOLDEN_GAMMA, Ordering::Relaxed)
.wrapping_add(GOLDEN_GAMMA);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
Expand Down
2 changes: 1 addition & 1 deletion src/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// and verified against.
///
/// This corresponds to the `info.version` field of the Apify OpenAPI document.
pub const API_SPEC_VERSION: &str = "v2-2026-06-25T142310Z";
pub const API_SPEC_VERSION: &str = "v2-2026-06-29T142258Z";
Loading