From 7f75cf73d476bc4c010179a3ac76ae876676a7bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 21:34:35 +0000 Subject: [PATCH] chore: sync Rust client with Apify OpenAPI spec v2-2026-06-29T142258Z Bump API_SPEC_VERSION to v2-2026-06-29T142258Z (from v2-2026-06-25T142310Z) and the crate to 0.4.2. An operation- and parameter-level audit against the new spec, with the JavaScript reference client as the parity authority for the in-scope surface, found the in-scope typed API unchanged, so this is a patch release with no public interface change. Also addresses review feedback: - run_store_actor example now discovers its actor through the Store API. - Documented DownloadItemsFormat variants (+ a download_items snippet), the forefront flag, and the untyped request-queue return shapes. - Account-usage example/doc derive the lookup date from the current day instead of a hardcoded historical date. - HTTP retry backoff tidied: atomic jitter via fetch_add, a total retry loop without a dead fallback branch, and saturating backoff arithmetic. --- CHANGELOG.md | 15 +++++++++++++++ Cargo.toml | 2 +- docs/misc.md | 8 +++++--- docs/storages.md | 36 +++++++++++++++++++++++++++++++++++ examples/get_account.rs | 13 ++++++------- examples/run_store_actor.rs | 36 +++++++++++++++++++++++++++++++---- src/http_client.rs | 38 ++++++++++++++++++------------------- src/version.rs | 2 +- 8 files changed, 115 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 468669f..ff371b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 2d56991..44969c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "apify-client" -version = "0.4.1" +version = "0.4.2" authors = ["Apify Technologies "] description = "An official, but experimental, AI-generated and AI-maintained Rust client for the Apify API (https://apify.com)." license = "Apache-2.0" diff --git a/docs/misc.md b/docs/misc.md index 973d5e4..2cf2360 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -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}"); diff --git a/docs/storages.md b/docs/storages.md index 75b698b..4ed567b 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -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`) — 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> { +let dataset = client.datasets().get_or_create(None).await?; +let csv: Vec = 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>)`. @@ -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` / diff --git a/examples/get_account.rs b/examples/get_account.rs index 61d718f..6573459 100644 --- a/examples/get_account.rs +++ b/examples/get_account.rs @@ -20,13 +20,12 @@ async fn main() -> Result<(), Box> { } // 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(()) diff --git a/examples/run_store_actor.rs b/examples/run_store_actor.rs index 60edd6d..d545f96 100644 --- a/examples/run_store_actor.rs +++ b/examples/run_store_actor.rs @@ -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> { 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::(None, Default::default(), Some(120)) .await?; println!("Run {} finished with status {:?}", run.id, run.status); diff --git a/src/http_client.rs b/src/http_client.rs index 22c03f2..8d503a1 100644 --- a/src/http_client.rs +++ b/src/http_client.rs @@ -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); @@ -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))`. @@ -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) diff --git a/src/version.rs b/src/version.rs index 221675b..5dcfbd8 100644 --- a/src/version.rs +++ b/src/version.rs @@ -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";