Skip to main content

apify_client/clients/
run.rs

1//! Client for a single Actor run (`/v2/actor-runs/{runId}` and nested routes).
2
3use serde::Serialize;
4
5use crate::clients::base::{
6    delete_resource, get_resource, post_action, post_with_body, update_resource, wait_for_finish,
7    ResourceContext,
8};
9use crate::clients::dataset::DatasetClient;
10use crate::clients::key_value_store::KeyValueStoreClient;
11use crate::clients::log::LogClient;
12use crate::clients::request_queue::RequestQueueClient;
13use crate::common::{to_safe_id, QueryParams};
14use crate::error::ApifyClientResult;
15use crate::http_client::HttpClient;
16use crate::models::ActorRun;
17
18/// Header the API uses to deduplicate charge requests (matching the reference client).
19const CHARGE_IDEMPOTENCY_HEADER: &str = "idempotency-key";
20
21/// Options for fetching an Actor's or task's last run
22/// ([`crate::clients::actor::ActorClient::last_run_with_options`] /
23/// [`crate::clients::task::TaskClient::last_run_with_options`]).
24///
25/// Both are sent as query parameters to the last-run endpoints
26/// (`GET /v2/actors/{actorId}/runs/last`, `GET /v2/actor-tasks/{actorTaskId}/runs/last`).
27/// `status` is the spec's documented optional filter on those endpoints; `origin` is not declared
28/// by the OpenAPI spec and is exposed only for parity with the reference client's
29/// `lastRun({ status, origin })`.
30#[derive(Debug, Default, Clone)]
31pub struct LastRunOptions {
32    /// Filter by run status (e.g. `"SUCCEEDED"`, `"FAILED"`, `"RUNNING"`). `None` leaves it
33    /// unfiltered.
34    pub status: Option<String>,
35    /// Filter by how the run was started; accepted values are the platform's run origins
36    /// (e.g. `"DEVELOPMENT"`, `"WEB"`, `"API"`, `"SCHEDULER"`). `None` leaves it unfiltered.
37    pub origin: Option<String>,
38}
39
40/// Options for resurrecting a finished run.
41#[derive(Debug, Default, Clone)]
42pub struct RunResurrectOptions {
43    /// Build tag/number to use; defaults to the original run's build.
44    pub build: Option<String>,
45    /// Memory in megabytes.
46    pub memory_mbytes: Option<i64>,
47    /// Timeout in seconds.
48    pub timeout_secs: Option<i64>,
49    /// Maximum number of dataset items to charge (pay-per-result Actors).
50    pub max_items: Option<i64>,
51    /// Maximum total charge in USD (pay-per-event Actors).
52    pub max_total_charge_usd: Option<f64>,
53    /// If `true`, restart the run automatically when it fails.
54    pub restart_on_error: Option<bool>,
55}
56
57/// Options for transforming a run into another Actor's run (metamorph).
58#[derive(Debug, Default, Clone)]
59pub struct RunMetamorphOptions {
60    /// Build tag/number of the target Actor to use (defaults to the target's default build).
61    pub build: Option<String>,
62    /// Content type of the input body. Defaults to `application/json` when unset.
63    pub content_type: Option<String>,
64}
65
66/// Options for charging a pay-per-event run via [`RunClient::charge`].
67#[derive(Debug, Default, Clone)]
68pub struct RunChargeOptions {
69    /// Name of the event to charge for. Required.
70    pub event_name: String,
71    /// Number of times to charge the event (defaults to `1`).
72    pub count: Option<i64>,
73    /// Idempotency key deduplicating the charge across retries. If `None`, one is
74    /// auto-generated as `{runId}-{eventName}-{timestampMillis}-{random}`, matching the
75    /// reference client, so a transport-retried charge is applied at most once.
76    pub idempotency_key: Option<String>,
77}
78
79/// Client for a specific Actor run.
80///
81/// In addition to CRUD-style operations, this client exposes the run's lifecycle
82/// actions (abort, metamorph, reboot, resurrect, charge) and provides access to the
83/// run's default dataset, key-value store, request queue and log.
84#[derive(Debug, Clone)]
85pub struct RunClient {
86    ctx: ResourceContext,
87    /// The run ID, retained so `charge` can build a per-run idempotency key.
88    id: String,
89}
90
91impl RunClient {
92    pub(crate) fn new(
93        _root: crate::client::ApifyClient,
94        http: HttpClient,
95        base_url: &str,
96        resource_path: &str,
97        id: &str,
98    ) -> Self {
99        Self {
100            ctx: ResourceContext::single(http, base_url, resource_path, id),
101            id: id.to_string(),
102        }
103    }
104
105    /// Adds a base query parameter to this run client, inherited by its requests (used by
106    /// `actor.last_run` / `task.last_run` to thread the `status` and `origin` filters).
107    pub(crate) fn set_base_param(&mut self, key: &str, value: &str) {
108        self.ctx
109            .base_params
110            .push_raw(key.to_string(), value.to_string());
111    }
112
113    /// Fetches the run object, or `None` if it does not exist.
114    pub async fn get(&self) -> ApifyClientResult<Option<ActorRun>> {
115        get_resource(&self.ctx, None, &QueryParams::new()).await
116    }
117
118    /// Updates the run (e.g. its status message) and returns the updated object.
119    pub async fn update<T: Serialize>(&self, new_fields: &T) -> ApifyClientResult<ActorRun> {
120        update_resource(&self.ctx, None, new_fields).await
121    }
122
123    /// Deletes the run.
124    pub async fn delete(&self) -> ApifyClientResult<()> {
125        delete_resource(&self.ctx, None).await
126    }
127
128    /// Aborts the run. `gracefully` is optional, matching the reference client's optional
129    /// `gracefully` option and the Go sibling's `Option<bool>`: `Some(true)` lets the run
130    /// perform cleanup first, `Some(false)` aborts immediately, and `None` omits the parameter
131    /// entirely so the server applies its default (immediate abort).
132    pub async fn abort(&self, gracefully: Option<bool>) -> ApifyClientResult<ActorRun> {
133        let mut params = QueryParams::new();
134        params.add_bool("gracefully", gracefully);
135        post_action(&self.ctx, Some("abort"), &params, None, None).await
136    }
137
138    /// Transforms the run into a run of another Actor (metamorph).
139    ///
140    /// `options.content_type` sets the content type of the input body (defaulting to
141    /// `application/json`), matching the reference client's `metamorph(..., { contentType })`.
142    pub async fn metamorph<T: Serialize>(
143        &self,
144        target_actor_id: &str,
145        input: Option<&T>,
146        options: RunMetamorphOptions,
147    ) -> ApifyClientResult<ActorRun> {
148        let mut params = QueryParams::new();
149        params
150            .add_str("targetActorId", Some(to_safe_id(target_actor_id)))
151            .add_str("build", options.build);
152        let body = match input {
153            Some(value) => Some(serde_json::to_vec(value)?),
154            None => None,
155        };
156        let content_type = options
157            .content_type
158            .as_deref()
159            .unwrap_or("application/json");
160        post_with_body(&self.ctx, Some("metamorph"), &params, body, content_type).await
161    }
162
163    /// Reboots the run (restarts its container, preserving the run ID and storages).
164    pub async fn reboot(&self) -> ApifyClientResult<ActorRun> {
165        post_action(&self.ctx, Some("reboot"), &QueryParams::new(), None, None).await
166    }
167
168    /// Resurrects a finished run, starting it again with (optionally overridden) settings.
169    pub async fn resurrect(&self, options: RunResurrectOptions) -> ApifyClientResult<ActorRun> {
170        let mut params = QueryParams::new();
171        params
172            .add_str("build", options.build)
173            .add_int("memory", options.memory_mbytes)
174            .add_int("timeout", options.timeout_secs)
175            .add_int("maxItems", options.max_items)
176            .add_float("maxTotalChargeUsd", options.max_total_charge_usd)
177            .add_bool("restartOnError", options.restart_on_error);
178        post_action(&self.ctx, Some("resurrect"), &params, None, None).await
179    }
180
181    /// Charges the run for a pay-per-event run, recording occurrences of a named event.
182    ///
183    /// An idempotency key is always sent (auto-generated when `options.idempotency_key` is
184    /// `None`), so a charge that is retried by the transport is applied at most once — matching
185    /// the reference client and preventing double-charging.
186    ///
187    /// The charge endpoint returns an empty body on success, so this issues the request
188    /// directly and treats any 2xx response as success (errors still surface normally).
189    pub async fn charge(&self, options: RunChargeOptions) -> ApifyClientResult<()> {
190        let count = options.count.unwrap_or(1);
191        let idempotency_key = options
192            .idempotency_key
193            .unwrap_or_else(|| self.generate_idempotency_key(&options.event_name));
194        let body = serde_json::json!({ "eventName": options.event_name, "count": count });
195        let body_bytes = serde_json::to_vec(&body)?;
196        let url = self.ctx.url(Some("charge"));
197        let mut headers = std::collections::HashMap::new();
198        headers.insert("Content-Type".to_string(), "application/json".to_string());
199        headers.insert(CHARGE_IDEMPOTENCY_HEADER.to_string(), idempotency_key);
200        // A successful `HttpClient::call` already guarantees a 2xx status; the (empty) body
201        // is intentionally ignored rather than parsed as a `data` envelope.
202        self.ctx
203            .http
204            .call(crate::http_client::HttpRequest {
205                method: crate::http_client::HttpMethod::Post,
206                url,
207                headers,
208                body: Some(body_bytes),
209                timeout: crate::clients::base::DEFAULT_REQUEST_TIMEOUT,
210            })
211            .await?;
212        Ok(())
213    }
214
215    /// Builds a per-charge idempotency key of the form
216    /// `{runId}-{eventName}-{timestampMillis}-{random}`, matching the reference client. The
217    /// suffix only needs to be unique enough to avoid collisions within the same millisecond;
218    /// it is derived from the sub-millisecond part of the current time (no crypto needed).
219    fn generate_idempotency_key(&self, event_name: &str) -> String {
220        let now = std::time::SystemTime::now()
221            .duration_since(std::time::UNIX_EPOCH)
222            .unwrap_or_default();
223        let millis = now.as_millis();
224        // Sub-millisecond nanos give a cheap, non-crypto "random" suffix (cf. JS Math.random()).
225        let random_suffix = now.subsec_nanos() % 1_000_000;
226        format!("{}-{event_name}-{millis}-{random_suffix}", self.id)
227    }
228
229    /// Waits (by client-side polling) for the run to reach a terminal state.
230    ///
231    /// `wait_secs` controls the wait budget:
232    /// - `None` polls indefinitely until the run reaches a terminal state.
233    /// - `Some(n)` bounds the wait to roughly `n` seconds; if the run has not finished by
234    ///   then, the **last fetched (still non-terminal) run is returned** rather than an
235    ///   error. Check `status` / `is_terminal()` on the result when using `Some`.
236    pub async fn wait_for_finish(&self, wait_secs: Option<i64>) -> ApifyClientResult<ActorRun> {
237        wait_for_finish(&self.ctx, wait_secs, |r: &ActorRun| r.is_terminal()).await
238    }
239
240    /// Returns a client for the run's default dataset.
241    pub fn dataset(&self) -> DatasetClient {
242        DatasetClient::nested(self.ctx.http.clone(), &self.ctx.url(None), "dataset")
243    }
244
245    /// Returns a client for the run's default key-value store.
246    pub fn key_value_store(&self) -> KeyValueStoreClient {
247        KeyValueStoreClient::nested(
248            self.ctx.http.clone(),
249            &self.ctx.url(None),
250            "key-value-store",
251        )
252    }
253
254    /// Returns a client for the run's default request queue.
255    pub fn request_queue(&self) -> RequestQueueClient {
256        RequestQueueClient::nested(self.ctx.http.clone(), &self.ctx.url(None), "request-queue")
257    }
258
259    /// Returns a client for the run's log.
260    pub fn log(&self) -> LogClient {
261        LogClient::nested(self.ctx.http.clone(), &self.ctx.url(None), "log")
262    }
263
264    /// Opens a live stream of the run's log for redirection.
265    ///
266    /// Convenience equivalent to `run.log().stream()` (mirrors the reference client's
267    /// `getStreamedLog`): yields log chunks as they arrive, so callers can forward them to
268    /// their own logger/stdout while the run is in progress.
269    pub async fn get_streamed_log(
270        &self,
271    ) -> ApifyClientResult<impl futures_util::Stream<Item = ApifyClientResult<Vec<u8>>>> {
272        self.log().stream().await
273    }
274
275    /// Opens a live stream of the run's log for redirection, applying the given
276    /// [`LogOptions`] (e.g. [`LogOptions::raw`] to stream the unprocessed log, which is the
277    /// form the JS reference's log redirection consumes internally).
278    ///
279    /// This is a Rust-specific convenience that simply forwards `LogOptions` to
280    /// [`LogClient::stream_with_options`]; it is not a 1:1 mirror of the JS `getStreamedLog`
281    /// method's signature (which takes redirect options and returns a `StreamedLog` object).
282    pub async fn get_streamed_log_with_options(
283        &self,
284        options: crate::clients::log::LogOptions,
285    ) -> ApifyClientResult<impl futures_util::Stream<Item = ApifyClientResult<Vec<u8>>>> {
286        self.log().stream_with_options(options).await
287    }
288}