//! `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 { 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)); }