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