From c40593b542a936b4133b2f76ce51315cdf3b38d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:13:51 +0000 Subject: [PATCH 1/2] chore: sync Rust client with Apify OpenAPI spec v2-2026-09-24T114302Z Charge endpoint's idempotency-key header is now required and documented to expire 3 minutes after the charge (client already always sends it); task publish limits changed to 10/Actor and 100/account. Both are doc-only changes, plus a hermetic unit test covering the charge request shape. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014rjxmcnw3QwFHZhZyM6udx --- CHANGELOG.md | 11 ++++++++++ Cargo.toml | 2 +- docs/runs.md | 4 +++- docs/tasks.md | 2 +- src/clients/run.rs | 8 +++++--- src/clients/task.rs | 6 ++++-- src/version.rs | 2 +- tests/unit_http.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 76 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ca3d0..d6ef020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.10.2] - 2026-09-25 + +### Changed +- Bumped `API_SPEC_VERSION` to `v2-2026-09-24T114302Z` (the `idempotency-key` header on the + charge endpoint is now documented as required and expiring 3 minutes after the charge, and + the task-publish description now states the 10-per-Actor / 100-per-account publication + limits; the client already always sends the idempotency key, so only doc comments changed). +- Documented the task-publish limits (10 per Actor, 100 per account) on `TaskClient::publish` + and the idempotency-key expiration window on `RunChargeOptions::idempotency_key`. +- Bumped crate version to `0.10.2`. + ## [0.10.1] - 2026-09-11 ### Changed diff --git a/Cargo.toml b/Cargo.toml index 0d43bd0..d1a66da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "apify-client" -version = "0.10.1" +version = "0.10.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/runs.md b/docs/runs.md index 27a3895..d8028ad 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -38,7 +38,9 @@ collections are available via `actor.runs()` and `task.runs()`. `RunMetamorphOptions`: `build`, `content_type` (both optional; `content_type` defaults to `application/json`). -`RunChargeOptions`: `event_name` (required), `count` (defaults to `1`), `idempotency_key` (auto-generated when omitted). +`RunChargeOptions`: `event_name` (required), `count` (defaults to `1`), `idempotency_key` +(auto-generated when omitted; the API requires this header and forgets the key 3 minutes after +the charge, so a retry past that window creates a new charge instead of being deduplicated). `ActorRun.status` is a stringly-typed `Option` carrying the API's run status. Known values are `READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `ABORTING`, `ABORTED`, `TIMING-OUT`, and diff --git a/docs/tasks.md b/docs/tasks.md index 82a8b29..bcf2fca 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -16,7 +16,7 @@ Obtained via `client.tasks()` (collection) and `client.task(id)` (single). |---|---|---|---| | `get()` | — | `Option` | Fetches the task. | | `update(fields)` | `&impl Serialize` | `Task` | Updates the task. | -| `publish()` | — | `Task` | Publishes the task on its public landing page (sets `isPublic: true`). Requires the task's Actor to be public and the task to have `publicConfig` set up. | +| `publish()` | — | `Task` | Publishes the task on its public landing page (sets `isPublic: true`). Requires the task's Actor to be public and the task to have `publicConfig` set up. An Actor may have at most 10 published tasks, an account at most 100. | | `unpublish()` | — | `Task` | Unpublishes the task from its public landing page (sets `isPublic: false`), preserving `publicConfig`. | | `delete()` | — | `()` | Deletes the task. | | `start(input, options)` | `Option<&impl Serialize>`, `ActorStartOptions` | `ActorRun` | Starts a run. See [`ActorStartOptions`](actors.md#actorstartoptions) for the full field list. | diff --git a/src/clients/run.rs b/src/clients/run.rs index 788956f..d9559ff 100644 --- a/src/clients/run.rs +++ b/src/clients/run.rs @@ -69,9 +69,11 @@ pub struct RunChargeOptions { pub event_name: String, /// Number of times to charge the event (defaults to `1`). pub count: Option, - /// Idempotency key deduplicating the charge across retries. If `None`, one is - /// auto-generated as `{runId}-{eventName}-{timestampMillis}-{random}`, matching the - /// reference client, so a transport-retried charge is applied at most once. + /// Idempotency key deduplicating the charge across retries. The API requires this header + /// on every charge request and forgets each key 3 minutes after the charge, so a retry sent + /// past that window with the same key creates a new charge rather than being deduplicated. + /// If `None`, one is auto-generated as `{runId}-{eventName}-{timestampMillis}-{random}`, + /// matching the reference client, so a transport-retried charge is applied at most once. pub idempotency_key: Option, } diff --git a/src/clients/task.rs b/src/clients/task.rs index 1cbc8a9..7670340 100644 --- a/src/clients/task.rs +++ b/src/clients/task.rs @@ -50,8 +50,10 @@ impl TaskClient { /// [`TaskClient::update`]. /// /// The task's Actor must be public and the task must already have its public display - /// configuration ([`Task::public_config`]) set up. Publishing an already-published task - /// does nothing. The returned [`Task::is_public`] reflects the new publication state. + /// configuration ([`Task::public_config`]) set up. An Actor can have at most 10 published + /// tasks, and an account at most 100 across all its Actors; contact Apify support to raise + /// either limit. Publishing an already-published task does nothing. The returned + /// [`Task::is_public`] reflects the new publication state. pub async fn publish(&self) -> ApifyClientResult { self.update(&serde_json::json!({ "isPublic": true })).await } diff --git a/src/version.rs b/src/version.rs index 87c1a75..8d7e630 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-09-10T091137Z"; +pub const API_SPEC_VERSION: &str = "v2-2026-09-24T114302Z"; diff --git a/tests/unit_http.rs b/tests/unit_http.rs index 141d195..585eb2c 100644 --- a/tests/unit_http.rs +++ b/tests/unit_http.rs @@ -826,6 +826,56 @@ async fn iterate_keys_zero_limit_sends_no_limit_and_walks_all() { ); } +/// `RunClient::charge` always sends the `idempotency-key` header (required by the spec) and the +/// `eventName`/`count` body, whether or not an explicit key is supplied. This is hermetic because +/// a real charge can only be made from inside a running pay-per-event Actor with that run's own +/// token, which the integration suite (running as an external account-level client) cannot do. +#[tokio::test] +async fn charge_sends_required_idempotency_key_header() { + let backend = MockBackend::new(vec![MockOutcome::Status(201, b"{}".to_vec())]); + let client = client_with(backend.clone(), 0); + + // No explicit key -> one is still auto-generated and sent. + client + .run("some-run-id") + .charge(apify_client::RunChargeOptions { + event_name: "ANALYZE_PAGE".to_owned(), + count: Some(2), + idempotency_key: None, + }) + .await + .expect("charge should succeed"); + assert!( + backend + .last_header("idempotency-key") + .is_some_and(|k| !k.is_empty()), + "an idempotency-key header must always be sent, even when none is supplied" + ); + let body = backend.last_body().expect("a body was sent"); + let parsed: serde_json::Value = serde_json::from_slice(&body).expect("valid JSON body"); + assert_eq!(parsed["eventName"], "ANALYZE_PAGE"); + assert_eq!(parsed["count"], 2); + + // Explicit key -> sent verbatim (not overwritten by an auto-generated one). + client + .run("some-run-id") + .charge(apify_client::RunChargeOptions { + event_name: "ANALYZE_PAGE".to_owned(), + count: None, + idempotency_key: Some("my-explicit-key".to_owned()), + }) + .await + .expect("charge should succeed"); + assert_eq!( + backend.last_header("idempotency-key").as_deref(), + Some("my-explicit-key"), + "an explicit idempotency key must be sent verbatim" + ); + let body = backend.last_body().expect("a body was sent"); + let parsed: serde_json::Value = serde_json::from_slice(&body).expect("valid JSON body"); + assert_eq!(parsed["count"], 1, "count defaults to 1 when omitted"); +} + /// A finite `iterate_keys` cap larger than one page must be clamped to the endpoint maximum per /// request (so it does not 400) while still yielding across pages until the cap or the store is /// exhausted. From 819fc021760b538de552e4ee02778cb13836f4b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:25:41 +0000 Subject: [PATCH 2/2] docs: align tasks.md publish-limit wording; test: assert charge URL Addresses the review's two non-blocking observations: docs/tasks.md now mirrors the "contact support" clause from the publish() doc comment, and the charge unit test also asserts the request URL. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014rjxmcnw3QwFHZhZyM6udx --- docs/tasks.md | 2 +- tests/unit_http.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/tasks.md b/docs/tasks.md index bcf2fca..3088742 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -16,7 +16,7 @@ Obtained via `client.tasks()` (collection) and `client.task(id)` (single). |---|---|---|---| | `get()` | — | `Option` | Fetches the task. | | `update(fields)` | `&impl Serialize` | `Task` | Updates the task. | -| `publish()` | — | `Task` | Publishes the task on its public landing page (sets `isPublic: true`). Requires the task's Actor to be public and the task to have `publicConfig` set up. An Actor may have at most 10 published tasks, an account at most 100. | +| `publish()` | — | `Task` | Publishes the task on its public landing page (sets `isPublic: true`). Requires the task's Actor to be public and the task to have `publicConfig` set up. An Actor may have at most 10 published tasks, an account at most 100 (contact Apify support to raise either limit). | | `unpublish()` | — | `Task` | Unpublishes the task from its public landing page (sets `isPublic: false`), preserving `publicConfig`. | | `delete()` | — | `()` | Deletes the task. | | `start(input, options)` | `Option<&impl Serialize>`, `ActorStartOptions` | `ActorRun` | Starts a run. See [`ActorStartOptions`](actors.md#actorstartoptions) for the full field list. | diff --git a/tests/unit_http.rs b/tests/unit_http.rs index 585eb2c..a0a1419 100644 --- a/tests/unit_http.rs +++ b/tests/unit_http.rs @@ -851,6 +851,11 @@ async fn charge_sends_required_idempotency_key_header() { .is_some_and(|k| !k.is_empty()), "an idempotency-key header must always be sent, even when none is supplied" ); + let url = backend.last_url().expect("a request was sent"); + assert!( + url.contains("/actor-runs/some-run-id/charge"), + "charge must POST to the run's /charge endpoint, got {url}" + ); let body = backend.last_body().expect("a body was sent"); let parsed: serde_json::Value = serde_json::from_slice(&body).expect("valid JSON body"); assert_eq!(parsed["eventName"], "ANALYZE_PAGE");