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