Three tests that would pass against a broken implementation: - `kv`/`docs` cross-app isolation asserted only that app B sees nothing — an execution_id-keyed (or script_id-keyed) bridge would pass that AND every single-execution round-trip, with app-scoped persistence entirely gone. Added the positive control: app A re-reads its own value in a LATER execution (a fresh execution_id/script_id via `baseline_request`), which only holds if storage is keyed by app_id. - `retry_policy_rejects_bogus_backoff` asserted a bare `is_err()`, which passes if `retry::policy` is unregistered (ErrorFunctionNotFound), on a script typo, or if every policy is rejected. Now it pins the message (`backoff`) and adds a valid-backoff control that must succeed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
7.0 KiB
Rust
203 lines
7.0 KiB
Rust
//! `retry::*` SDK bridge integration tests — runs real Rhai scripts
|
|
//! that build a Policy, wrap a closure, and verify the retry / on_codes
|
|
//! behavior end-to-end. Sleep delays are kept to 1ms so the suite runs
|
|
//! fast.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
|
use picloud_shared::{
|
|
AppId, ExecutionId, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter,
|
|
NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource,
|
|
NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService, RequestId, ScriptId,
|
|
ScriptSandbox, Services,
|
|
};
|
|
use serde_json::Value;
|
|
|
|
fn build_engine() -> Arc<Engine> {
|
|
let services = Services::new(
|
|
Arc::new(NoopKvService),
|
|
Arc::new(NoopDocsService),
|
|
Arc::new(NoopDeadLetterService),
|
|
Arc::new(NoopEventEmitter),
|
|
Arc::new(NoopModuleSource),
|
|
Arc::new(NoopHttpService),
|
|
Arc::new(NoopFilesService),
|
|
Arc::new(NoopPubsubService),
|
|
Arc::new(NoopSecretsService),
|
|
Arc::new(NoopEmailService),
|
|
Arc::new(NoopUsersService),
|
|
Arc::new(NoopQueueService),
|
|
Arc::new(NoopInvokeService),
|
|
Arc::new(picloud_shared::NoopVarsService),
|
|
);
|
|
Arc::new(Engine::new(Limits::default(), services))
|
|
}
|
|
|
|
fn baseline_request(app_id: AppId) -> ExecRequest {
|
|
let execution_id = ExecutionId::new();
|
|
ExecRequest {
|
|
execution_id,
|
|
request_id: RequestId::new(),
|
|
script_id: ScriptId::new(),
|
|
script_name: "retry-test".into(),
|
|
invocation_type: InvocationType::Http,
|
|
path: "/retry-test".into(),
|
|
method: String::new(),
|
|
headers: BTreeMap::new(),
|
|
body: Value::Null,
|
|
params: BTreeMap::new(),
|
|
query: BTreeMap::new(),
|
|
rest: String::new(),
|
|
sandbox_overrides: ScriptSandbox::default(),
|
|
app_id,
|
|
script_owner: None,
|
|
principal: None,
|
|
trigger_depth: 0,
|
|
root_execution_id: execution_id,
|
|
is_dead_letter_handler: false,
|
|
event: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn retry_run_succeeds_first_try() {
|
|
let engine = build_engine();
|
|
let src = r"
|
|
let p = retry::policy(#{ max_attempts: 3, base_ms: 1 });
|
|
let v = retry::run(p, || 42);
|
|
#{ statusCode: 200, body: v }
|
|
"
|
|
.to_string();
|
|
let req = baseline_request(AppId::new());
|
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(resp.body, serde_json::json!(42));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn retry_run_surfaces_after_max_attempts() {
|
|
let engine = build_engine();
|
|
let src = r#"
|
|
let p = retry::policy(#{ max_attempts: 3, base_ms: 1, jitter_pct: 0 });
|
|
retry::run(p, || { throw "boom" });
|
|
"#
|
|
.to_string();
|
|
let req = baseline_request(AppId::new());
|
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
|
.await
|
|
.unwrap();
|
|
let err = res.unwrap_err().to_string();
|
|
assert!(err.contains("boom"), "expected 'boom' in error, got: {err}");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn retry_on_codes_filters_unmatched_errors() {
|
|
let engine = build_engine();
|
|
// Throw a message NOT in the filter; retry::run must surface
|
|
// immediately (no retries).
|
|
let src = r#"
|
|
let p = retry::policy(#{ max_attempts: 5, base_ms: 1, jitter_pct: 0 });
|
|
let p2 = retry::on_codes(p, ["http: 503"]);
|
|
retry::run(p2, || { throw "other failure" });
|
|
"#
|
|
.to_string();
|
|
let req = baseline_request(AppId::new());
|
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
|
.await
|
|
.unwrap();
|
|
let err = res.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("other failure"),
|
|
"expected immediate surface, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn retry_policy_clamps_visible_at_script_layer() {
|
|
// jitter_pct = 999 should clamp to 100 silently; the policy is then
|
|
// usable.
|
|
let engine = build_engine();
|
|
let src = r#"
|
|
let p = retry::policy(#{
|
|
max_attempts: 30, base_ms: 100, jitter_pct: 999, backoff: "constant"
|
|
});
|
|
let v = retry::run(p, || 1);
|
|
#{ statusCode: 200, body: v }
|
|
"#
|
|
.to_string();
|
|
let req = baseline_request(AppId::new());
|
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(resp.body, serde_json::json!(1));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn retry_policy_rejects_bogus_backoff() {
|
|
let engine = build_engine();
|
|
|
|
// Pin the REASON. A bare `is_err()` would pass if `retry::policy` were not
|
|
// registered at all (ErrorFunctionNotFound), or on a typo in the script
|
|
// source, or if EVERY policy were rejected — none of which is "an invalid
|
|
// backoff is rejected". The message must name `backoff`.
|
|
let src = r#"retry::policy(#{ backoff: "bogus" });"#.to_string();
|
|
let req = baseline_request(AppId::new());
|
|
let err = tokio::task::spawn_blocking({
|
|
let engine = engine.clone();
|
|
move || engine.execute(&src, req)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(
|
|
err.to_lowercase().contains("backoff"),
|
|
"the rejection must be about the backoff value specifically, got: {err}"
|
|
);
|
|
|
|
// Control: the SAME call with a VALID backoff succeeds — proving `retry::policy`
|
|
// is registered and does not reject everything, so the failure above is
|
|
// specific to the bad value.
|
|
let ok_src = r#"
|
|
let p = retry::policy(#{ backoff: "constant", max_attempts: 2, base_ms: 1 });
|
|
retry::run(p, || 7)
|
|
"#
|
|
.to_string();
|
|
let req = baseline_request(AppId::new());
|
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&ok_src, req))
|
|
.await
|
|
.unwrap()
|
|
.expect("a valid backoff must be accepted");
|
|
assert_eq!(resp.body, serde_json::json!(7));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn retry_run_eventually_succeeds() {
|
|
// Use an in-engine counter to simulate "fails 2 times then succeeds".
|
|
let engine = build_engine();
|
|
let counter = Arc::new(Mutex::new(0_i64));
|
|
let _ = counter; // counter via Rhai-side `let` instead
|
|
|
|
let src = r#"
|
|
let attempts = 0;
|
|
let p = retry::policy(#{ max_attempts: 5, base_ms: 1, jitter_pct: 0 });
|
|
let v = retry::run(p, || {
|
|
attempts += 1;
|
|
if attempts < 3 { throw "transient" } else { attempts }
|
|
});
|
|
#{ statusCode: 200, body: v }
|
|
"#
|
|
.to_string();
|
|
let req = baseline_request(AppId::new());
|
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(resp.body, serde_json::json!(3));
|
|
}
|