Skip to main content

ironflow_api/routes/
retry_run.rs

1//! `POST /api/v1/runs/:id/retry` — Retry a failed run.
2
3use axum::extract::{Path, Query, State};
4use axum::http::StatusCode;
5use axum::response::IntoResponse;
6use chrono::Utc;
7use ironflow_auth::extractor::Authenticated;
8use ironflow_engine::error::{EngineError, HANDLER_VERSION_MISMATCH_CODE};
9use ironflow_engine::notify::{Event, RetryForcedEvent, RunCreatedEvent};
10use ironflow_store::entities::ApiKeyScope;
11use ironflow_store::models::{NewRun, RunStatus, TriggerKind};
12use serde::Deserialize;
13use uuid::Uuid;
14
15use crate::actor::run_actor_of;
16use crate::authorization::AuthorizeExt;
17use crate::entities::RunResponse;
18use crate::error::ApiError;
19use crate::response::ok;
20use crate::state::AppState;
21
22/// Query parameters for `POST /api/v1/runs/:id/retry`.
23#[derive(Debug, Deserialize, Default)]
24pub struct RetryQuery {
25    /// Force the retry even when the handler version has changed.
26    #[serde(default)]
27    pub force: bool,
28}
29
30/// Retry a failed run.
31///
32/// Creates a new `Pending` run with `TriggerKind::Retry` pointing to the
33/// original. Returns 400 if the run is not in a retryable state, 409 if an
34/// automatic retry is already armed, 409 if the handler version has
35/// changed since the original run (pass `?force=true` to override), and 409
36/// `CONCURRENCY_CONFLICT` if another active run took the original's
37/// concurrency key since.
38///
39/// Returns 429 `MONTHLY_BUDGET_EXCEEDED` if the global monthly cost quota is exhausted.
40#[cfg_attr(
41    feature = "openapi",
42    utoipa::path(
43        post,
44        path = "/api/v1/runs/{id}/retry",
45        tags = ["runs"],
46        params(
47            ("id" = Uuid, Path, description = "Run ID"),
48            ("force" = Option<bool>, Query, description = "Force retry despite handler version mismatch"),
49        ),
50        responses(
51            (status = 201, description = "Run retry created successfully", body = RunResponse),
52            (status = 400, description = "Run cannot be retried"),
53            (status = 401, description = "Unauthorized"),
54            (status = 403, description = "Forbidden"),
55            (status = 404, description = "Run not found"),
56            (status = 409, description = "Version mismatch, automatic retry already armed, or concurrency key held by an active run (CONCURRENCY_CONFLICT)"),
57            (status = 429, description = "Monthly cost quota exhausted (MONTHLY_BUDGET_EXCEEDED)")
58        ),
59        security(("Bearer" = []))
60    )
61)]
62pub async fn retry_run(
63    auth: Authenticated,
64    State(state): State<AppState>,
65    Path(id): Path<Uuid>,
66    Query(query): Query<RetryQuery>,
67) -> Result<impl IntoResponse, ApiError> {
68    auth.require(ApiKeyScope::RunsManage, true)?;
69
70    let original = state.get_run_or_404(id).await?;
71
72    // Retrying means an automatic retry is already armed; creating a manual
73    // retry on top would produce a duplicate run once the timer fires.
74    if original.status.state == RunStatus::Retrying {
75        return Err(ApiError::Conflict(
76            "run is already waiting for an automatic retry; cancel it first to retry manually"
77                .to_string(),
78        ));
79    }
80
81    if !matches!(
82        original.status.state,
83        RunStatus::Failed | RunStatus::Cancelled | RunStatus::Warning
84    ) {
85        return Err(ApiError::BadRequest(format!(
86            "cannot retry run in {} state",
87            original.status.state
88        )));
89    }
90
91    let force = query.force;
92    let handler = state.engine.get_handler(&original.workflow_name);
93    let current_version = handler.and_then(|h| h.version().map(str::to_string));
94
95    if let Some(handler) = &handler
96        && !handler.is_version_compatible(original.handler_version.as_deref())
97        && !force
98    {
99        return Err(ApiError::Conflict(format!(
100            "{}: handler '{}' is now at version {}, but the run was created \
101             with version {}. Pass ?force=true to override.",
102            HANDLER_VERSION_MISMATCH_CODE,
103            original.workflow_name,
104            current_version.as_deref().unwrap_or("unknown"),
105            original.handler_version.as_deref().unwrap_or("unknown"),
106        )));
107    }
108
109    state
110        .engine
111        .check_monthly_quota(&original.workflow_name)
112        .await
113        .map_err(|e| match e {
114            EngineError::MonthlyBudgetExceeded { .. } => {
115                ApiError::MonthlyBudgetExceeded(e.to_string())
116            }
117            other => ApiError::Internal(other.to_string()),
118        })?;
119
120    // When the handler is still registered, use its current version.
121    // When unregistered (e.g. removed between deploys), preserve the
122    // original version so version-tracking information is not lost.
123    let effective_version = current_version.clone().or(original.handler_version.clone());
124
125    let new_run = state
126        .store
127        .create_run(NewRun {
128            workflow_name: original.workflow_name.clone(),
129            trigger: TriggerKind::Retry { parent_run_id: id },
130            payload: original.payload,
131            max_retries: original.max_retries,
132            handler_version: effective_version,
133            labels: original.labels,
134            scheduled_at: None,
135            // The retry is attributed to the user who triggered it, not the
136            // original author, so the audit trail shows who actually acted.
137            created_by: Some(run_actor_of(&auth)),
138            // A retry must not inherit the parent's idempotency key: it is a
139            // new logical operation and must be eligible for its own dedup.
140            idempotency_key: None,
141            // The retry continues the same work, so it stays exclusive under the
142            // same key. The original is terminal and no longer holds it; a run
143            // that took the key since then makes the retry conflict.
144            concurrency_key: original.concurrency_key,
145            // The retry keeps the priority of the run it continues.
146            priority: original.priority,
147            // The retry stays in the same concurrency groups under the same limits.
148            concurrency_limits: original.concurrency_limits,
149            // Inherit the original cost cap so budget constraints survive retries.
150            max_cost_usd: original.max_cost_usd,
151            // The retry needs the same kind of worker as the original.
152            worker_tags: original.worker_tags,
153        })
154        .await?
155        .into_run();
156
157    // Only emit RetryForced when both the handler is registered (so
158    // current_version is meaningful) and the versions actually differ.
159    if force && handler.is_some() && original.handler_version != current_version {
160        state
161            .engine
162            .event_publisher()
163            .publish(Event::RetryForced(RetryForcedEvent {
164                run_id: new_run.id,
165                workflow_name: original.workflow_name.clone(),
166                original_version: original.handler_version.unwrap_or_default(),
167                current_version: current_version.unwrap_or_default(),
168                at: Utc::now(),
169            }));
170    }
171
172    state
173        .engine
174        .event_publisher()
175        .publish(Event::RunCreated(RunCreatedEvent {
176            run_id: new_run.id,
177            workflow_name: new_run.workflow_name.clone(),
178            at: Utc::now(),
179        }));
180
181    Ok((StatusCode::CREATED, ok(RunResponse::from(new_run))))
182}
183
184#[cfg(test)]
185mod tests {
186    use std::collections::HashMap;
187
188    use axum::Router;
189    use axum::body::Body;
190    use axum::http::{Request, StatusCode as HttpStatusCode};
191    use axum::response::Response;
192    use axum::routing::post;
193    use http_body_util::BodyExt;
194    use ironflow_auth::jwt::{AccessToken, JwtConfig};
195    use ironflow_core::providers::claude::ClaudeCodeProvider;
196    use ironflow_engine::budget::BudgetConfig;
197    use ironflow_engine::context::WorkflowContext;
198    use ironflow_engine::engine::Engine;
199    use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
200    use ironflow_engine::notify::Event;
201    use ironflow_store::memory::InMemoryStore;
202    use ironflow_store::models::{NewRun, NewUser, RunActor, RunFilter, RunStatus, TriggerKind};
203    use ironflow_store::store::RunStore;
204    use ironflow_store::user_store::UserStore;
205    use rust_decimal::Decimal;
206    use serde_json::{Value as JsonValue, from_slice, from_value, json};
207    use std::sync::Arc;
208    use tokio::sync::broadcast;
209    use tower::ServiceExt;
210    use uuid::Uuid;
211
212    use super::*;
213    use crate::routes::test_helpers::create_user_auth_header;
214
215    fn test_state(store: Arc<InMemoryStore>) -> AppState {
216        test_state_with_quota(store, BudgetConfig::new())
217    }
218
219    #[tokio::test]
220    async fn retry_failed_run() {
221        let store = Arc::new(InMemoryStore::new());
222        let run = store
223            .create_run(NewRun {
224                created_by: None,
225                workflow_name: "test".to_string(),
226                trigger: TriggerKind::Manual,
227                payload: json!({"key": "value"}),
228                max_retries: 3,
229                handler_version: None,
230                labels: HashMap::new(),
231                scheduled_at: None,
232                idempotency_key: None,
233                concurrency_key: None,
234                priority: 0,
235                concurrency_limits: Vec::new(),
236                max_cost_usd: None,
237                worker_tags: Vec::new(),
238            })
239            .await
240            .unwrap()
241            .into_run();
242
243        store
244            .update_run_status(run.id, RunStatus::Running)
245            .await
246            .unwrap();
247        store
248            .update_run_status(run.id, RunStatus::Failed)
249            .await
250            .unwrap();
251
252        let state = test_state(store.clone());
253        let auth_header = create_user_auth_header(&state, "testuser", true).await;
254        let app = Router::new()
255            .route("/{id}/retry", post(retry_run))
256            .with_state(state);
257
258        let req = Request::builder()
259            .method("POST")
260            .uri(format!("/{}/retry", run.id))
261            .header("content-type", "application/json")
262            .header("authorization", auth_header)
263            .body(Body::from("{}"))
264            .unwrap();
265
266        let resp = app.oneshot(req).await.unwrap();
267        assert_eq!(resp.status(), HttpStatusCode::CREATED);
268
269        let body = resp.into_body().collect().await.unwrap().to_bytes();
270        let json_val: JsonValue = from_slice(&body).unwrap();
271        let new_id: Uuid = from_value(json_val["data"]["id"].clone()).unwrap();
272
273        let new_run = store.get_run(new_id).await.unwrap().unwrap();
274        assert_eq!(new_run.status.state, RunStatus::Pending);
275        assert!(matches!(new_run.trigger, TriggerKind::Retry { .. }));
276    }
277
278    #[tokio::test]
279    async fn retry_inherits_the_original_cost_cap() {
280        let store = Arc::new(InMemoryStore::new());
281        let cap = Decimal::new(250, 2);
282        let run = store
283            .create_run(NewRun {
284                workflow_name: "test".to_string(),
285                trigger: TriggerKind::Manual,
286                payload: json!({}),
287                max_retries: 1,
288                handler_version: None,
289                labels: HashMap::new(),
290                scheduled_at: None,
291                created_by: None,
292                idempotency_key: None,
293                concurrency_key: None,
294                priority: 0,
295                concurrency_limits: Vec::new(),
296                max_cost_usd: Some(cap),
297                worker_tags: Vec::new(),
298            })
299            .await
300            .unwrap()
301            .into_run();
302
303        // A run cancelled for reaching its cap is retryable.
304        store
305            .update_run_status(run.id, RunStatus::Running)
306            .await
307            .unwrap();
308        store
309            .update_run_status(run.id, RunStatus::Cancelled)
310            .await
311            .unwrap();
312
313        let state = test_state(store.clone());
314        let auth_header = create_user_auth_header(&state, "testuser", true).await;
315        let app = Router::new()
316            .route("/{id}/retry", post(retry_run))
317            .with_state(state);
318
319        let req = Request::builder()
320            .method("POST")
321            .uri(format!("/{}/retry", run.id))
322            .header("content-type", "application/json")
323            .header("authorization", auth_header)
324            .body(Body::from("{}"))
325            .unwrap();
326
327        let resp = app.oneshot(req).await.unwrap();
328        assert_eq!(resp.status(), HttpStatusCode::CREATED);
329
330        let body = resp.into_body().collect().await.unwrap().to_bytes();
331        let json_val: JsonValue = from_slice(&body).unwrap();
332        let new_id: Uuid = from_value(json_val["data"]["id"].clone()).unwrap();
333
334        let new_run = store.get_run(new_id).await.unwrap().unwrap();
335        assert_eq!(new_run.max_cost_usd, Some(cap));
336    }
337
338    #[tokio::test]
339    async fn retry_keeps_the_original_priority() {
340        let store = Arc::new(InMemoryStore::new());
341        let run = store
342            .create_run(NewRun {
343                workflow_name: "test".to_string(),
344                trigger: TriggerKind::Manual,
345                payload: json!({}),
346                max_retries: 1,
347                handler_version: None,
348                labels: HashMap::new(),
349                scheduled_at: None,
350                created_by: None,
351                idempotency_key: None,
352                concurrency_key: None,
353                priority: 42,
354                concurrency_limits: Vec::new(),
355                max_cost_usd: None,
356                worker_tags: Vec::new(),
357            })
358            .await
359            .unwrap()
360            .into_run();
361        store
362            .update_run_status(run.id, RunStatus::Running)
363            .await
364            .unwrap();
365        store
366            .update_run_status(run.id, RunStatus::Failed)
367            .await
368            .unwrap();
369
370        let state = test_state(store.clone());
371        let auth_header = create_user_auth_header(&state, "testuser", true).await;
372        let app = Router::new()
373            .route("/{id}/retry", post(retry_run))
374            .with_state(state);
375
376        let req = Request::builder()
377            .method("POST")
378            .uri(format!("/{}/retry", run.id))
379            .header("content-type", "application/json")
380            .header("authorization", auth_header)
381            .body(Body::from("{}"))
382            .unwrap();
383
384        let resp = app.oneshot(req).await.unwrap();
385        assert_eq!(resp.status(), HttpStatusCode::CREATED);
386
387        let body = resp.into_body().collect().await.unwrap().to_bytes();
388        let json_val: JsonValue = from_slice(&body).unwrap();
389        assert_eq!(json_val["data"]["priority"], 42);
390        let new_id: Uuid = from_value(json_val["data"]["id"].clone()).unwrap();
391
392        let new_run = store.get_run(new_id).await.unwrap().unwrap();
393        assert_eq!(new_run.priority, 42);
394    }
395
396    #[tokio::test]
397    async fn retry_keeps_the_original_worker_tags() {
398        let store = Arc::new(InMemoryStore::new());
399        let tags = vec!["gpu".to_string(), "region:eu".to_string()];
400        let run = store
401            .create_run(NewRun {
402                workflow_name: "test".to_string(),
403                trigger: TriggerKind::Manual,
404                payload: json!({}),
405                max_retries: 0,
406                handler_version: None,
407                labels: HashMap::new(),
408                scheduled_at: None,
409                created_by: None,
410                idempotency_key: None,
411                concurrency_key: None,
412                priority: 0,
413                concurrency_limits: Vec::new(),
414                max_cost_usd: None,
415                worker_tags: tags.clone(),
416            })
417            .await
418            .unwrap()
419            .into_run();
420        store
421            .update_run_status(run.id, RunStatus::Running)
422            .await
423            .unwrap();
424        store
425            .update_run_status(run.id, RunStatus::Failed)
426            .await
427            .unwrap();
428
429        let state = test_state(store.clone());
430        let auth_header = create_user_auth_header(&state, "testuser", true).await;
431        let app = Router::new()
432            .route("/{id}/retry", post(retry_run))
433            .with_state(state);
434
435        let req = Request::builder()
436            .method("POST")
437            .uri(format!("/{}/retry", run.id))
438            .header("content-type", "application/json")
439            .header("authorization", auth_header)
440            .body(Body::from("{}"))
441            .unwrap();
442
443        let resp = app.oneshot(req).await.unwrap();
444        assert_eq!(resp.status(), HttpStatusCode::CREATED);
445
446        let body = resp.into_body().collect().await.unwrap().to_bytes();
447        let json_val: JsonValue = from_slice(&body).unwrap();
448        assert_eq!(json_val["data"]["worker_tags"], json!(["gpu", "region:eu"]));
449        let new_id: Uuid = from_value(json_val["data"]["id"].clone()).unwrap();
450
451        let new_run = store.get_run(new_id).await.unwrap().unwrap();
452        assert_eq!(new_run.worker_tags, tags);
453    }
454
455    #[tokio::test]
456    async fn retry_pending_run_returns_400() {
457        let store = Arc::new(InMemoryStore::new());
458        let run = store
459            .create_run(NewRun {
460                created_by: None,
461                workflow_name: "test".to_string(),
462                trigger: TriggerKind::Manual,
463                payload: json!({}),
464                max_retries: 0,
465                handler_version: None,
466                labels: HashMap::new(),
467                scheduled_at: None,
468                idempotency_key: None,
469                concurrency_key: None,
470                priority: 0,
471                concurrency_limits: Vec::new(),
472                max_cost_usd: None,
473                worker_tags: Vec::new(),
474            })
475            .await
476            .unwrap()
477            .into_run();
478
479        let state = test_state(store);
480        let auth_header = create_user_auth_header(&state, "testuser", true).await;
481        let app = Router::new()
482            .route("/{id}/retry", post(retry_run))
483            .with_state(state);
484
485        let req = Request::builder()
486            .method("POST")
487            .uri(format!("/{}/retry", run.id))
488            .header("content-type", "application/json")
489            .header("authorization", auth_header)
490            .body(Body::from("{}"))
491            .unwrap();
492
493        let resp = app.oneshot(req).await.unwrap();
494        assert_eq!(resp.status(), HttpStatusCode::BAD_REQUEST);
495    }
496
497    #[tokio::test]
498    async fn retry_completed_run_returns_400() {
499        let store = Arc::new(InMemoryStore::new());
500        let run = store
501            .create_run(NewRun {
502                created_by: None,
503                workflow_name: "test".to_string(),
504                trigger: TriggerKind::Manual,
505                payload: json!({}),
506                max_retries: 0,
507                handler_version: None,
508                labels: HashMap::new(),
509                scheduled_at: None,
510                idempotency_key: None,
511                concurrency_key: None,
512                priority: 0,
513                concurrency_limits: Vec::new(),
514                max_cost_usd: None,
515                worker_tags: Vec::new(),
516            })
517            .await
518            .unwrap()
519            .into_run();
520
521        store
522            .update_run_status(run.id, RunStatus::Running)
523            .await
524            .unwrap();
525        store
526            .update_run_status(run.id, RunStatus::Completed)
527            .await
528            .unwrap();
529
530        let state = test_state(store);
531        let auth_header = create_user_auth_header(&state, "testuser", true).await;
532        let app = Router::new()
533            .route("/{id}/retry", post(retry_run))
534            .with_state(state);
535
536        let req = Request::builder()
537            .method("POST")
538            .uri(format!("/{}/retry", run.id))
539            .header("content-type", "application/json")
540            .header("authorization", auth_header)
541            .body(Body::from("{}"))
542            .unwrap();
543
544        let resp = app.oneshot(req).await.unwrap();
545        assert_eq!(resp.status(), HttpStatusCode::BAD_REQUEST);
546    }
547
548    #[tokio::test]
549    async fn retry_running_run_returns_400() {
550        let store = Arc::new(InMemoryStore::new());
551        let run = store
552            .create_run(NewRun {
553                created_by: None,
554                workflow_name: "test".to_string(),
555                trigger: TriggerKind::Manual,
556                payload: json!({}),
557                max_retries: 0,
558                handler_version: None,
559                labels: HashMap::new(),
560                scheduled_at: None,
561                idempotency_key: None,
562                concurrency_key: None,
563                priority: 0,
564                concurrency_limits: Vec::new(),
565                max_cost_usd: None,
566                worker_tags: Vec::new(),
567            })
568            .await
569            .unwrap()
570            .into_run();
571
572        store
573            .update_run_status(run.id, RunStatus::Running)
574            .await
575            .unwrap();
576
577        let state = test_state(store);
578        let auth_header = create_user_auth_header(&state, "testuser", true).await;
579        let app = Router::new()
580            .route("/{id}/retry", post(retry_run))
581            .with_state(state);
582
583        let req = Request::builder()
584            .method("POST")
585            .uri(format!("/{}/retry", run.id))
586            .header("content-type", "application/json")
587            .header("authorization", auth_header)
588            .body(Body::from("{}"))
589            .unwrap();
590
591        let resp = app.oneshot(req).await.unwrap();
592        assert_eq!(resp.status(), HttpStatusCode::BAD_REQUEST);
593    }
594
595    #[tokio::test]
596    async fn retry_run_awaiting_automatic_retry_returns_409() {
597        let store = Arc::new(InMemoryStore::new());
598        let run = store
599            .create_run(NewRun {
600                created_by: None,
601                workflow_name: "test".to_string(),
602                trigger: TriggerKind::Manual,
603                payload: json!({}),
604                max_retries: 3,
605                handler_version: None,
606                labels: HashMap::new(),
607                scheduled_at: None,
608                idempotency_key: None,
609                concurrency_key: None,
610                priority: 0,
611                concurrency_limits: Vec::new(),
612                max_cost_usd: None,
613                worker_tags: Vec::new(),
614            })
615            .await
616            .unwrap()
617            .into_run();
618
619        store
620            .update_run_status(run.id, RunStatus::Running)
621            .await
622            .unwrap();
623        store
624            .update_run_status(run.id, RunStatus::Retrying)
625            .await
626            .unwrap();
627
628        let state = test_state(store.clone());
629        let auth_header = create_user_auth_header(&state, "testuser", true).await;
630        let app = Router::new()
631            .route("/{id}/retry", post(retry_run))
632            .with_state(state);
633
634        let req = Request::builder()
635            .method("POST")
636            .uri(format!("/{}/retry", run.id))
637            .header("content-type", "application/json")
638            .header("authorization", auth_header)
639            .body(Body::from("{}"))
640            .unwrap();
641
642        let resp = app.oneshot(req).await.unwrap();
643        assert_eq!(resp.status(), HttpStatusCode::CONFLICT);
644
645        // No duplicate run was created.
646        let runs = store
647            .list_runs(ironflow_store::models::RunFilter::default(), 1, 50)
648            .await
649            .unwrap();
650        assert_eq!(runs.total, 1);
651    }
652
653    #[tokio::test]
654    async fn retry_cancelled_run_is_allowed() {
655        let store = Arc::new(InMemoryStore::new());
656        let run = store
657            .create_run(NewRun {
658                created_by: None,
659                workflow_name: "test".to_string(),
660                trigger: TriggerKind::Manual,
661                payload: json!({}),
662                max_retries: 0,
663                handler_version: None,
664                labels: HashMap::new(),
665                scheduled_at: None,
666                idempotency_key: None,
667                concurrency_key: None,
668                priority: 0,
669                concurrency_limits: Vec::new(),
670                max_cost_usd: None,
671                worker_tags: Vec::new(),
672            })
673            .await
674            .unwrap()
675            .into_run();
676
677        store
678            .update_run_status(run.id, RunStatus::Cancelled)
679            .await
680            .unwrap();
681
682        let state = test_state(store);
683        let auth_header = create_user_auth_header(&state, "testuser", true).await;
684        let app = Router::new()
685            .route("/{id}/retry", post(retry_run))
686            .with_state(state);
687
688        let req = Request::builder()
689            .method("POST")
690            .uri(format!("/{}/retry", run.id))
691            .header("content-type", "application/json")
692            .header("authorization", auth_header)
693            .body(Body::from("{}"))
694            .unwrap();
695
696        let resp = app.oneshot(req).await.unwrap();
697        assert_eq!(resp.status(), HttpStatusCode::CREATED);
698    }
699
700    #[tokio::test]
701    async fn retry_nonexistent_run_returns_404() {
702        let store = Arc::new(InMemoryStore::new());
703        let state = test_state(store);
704        let auth_header = create_user_auth_header(&state, "testuser", true).await;
705        let app = Router::new()
706            .route("/{id}/retry", post(retry_run))
707            .with_state(state);
708
709        let req = Request::builder()
710            .method("POST")
711            .uri(format!("/{}/retry", Uuid::now_v7()))
712            .header("content-type", "application/json")
713            .header("authorization", auth_header)
714            .body(Body::from("{}"))
715            .unwrap();
716
717        let resp = app.oneshot(req).await.unwrap();
718        assert_eq!(resp.status(), HttpStatusCode::NOT_FOUND);
719    }
720
721    // ---- created_by ----
722
723    #[tokio::test]
724    async fn retry_attributes_the_new_run_to_the_caller_not_the_original_author() {
725        let store = Arc::new(InMemoryStore::new());
726        let original_author = store
727            .create_user(NewUser {
728                email: "alice@example.com".to_string(),
729                username: "alice".to_string(),
730                password_hash: "hash".to_string(),
731                is_admin: Some(true),
732            })
733            .await
734            .unwrap();
735        let retrying_user = store
736            .create_user(NewUser {
737                email: "bob@example.com".to_string(),
738                username: "bob".to_string(),
739                password_hash: "hash".to_string(),
740                is_admin: Some(true),
741            })
742            .await
743            .unwrap();
744
745        let run = store
746            .create_run(NewRun {
747                workflow_name: "test".to_string(),
748                trigger: TriggerKind::Api,
749                payload: json!({}),
750                max_retries: 3,
751                handler_version: None,
752                labels: HashMap::new(),
753                scheduled_at: None,
754                created_by: Some(RunActor::User {
755                    user_id: original_author.id,
756                }),
757                idempotency_key: None,
758                concurrency_key: None,
759                priority: 0,
760                concurrency_limits: Vec::new(),
761                max_cost_usd: None,
762                worker_tags: Vec::new(),
763            })
764            .await
765            .unwrap()
766            .into_run();
767
768        store
769            .update_run_status(run.id, RunStatus::Running)
770            .await
771            .unwrap();
772        store
773            .update_run_status(run.id, RunStatus::Failed)
774            .await
775            .unwrap();
776
777        let state = test_state(store.clone());
778        let token =
779            AccessToken::for_user(retrying_user.id, "bob", true, &state.jwt_config).unwrap();
780        let app = Router::new()
781            .route("/{id}/retry", post(retry_run))
782            .with_state(state);
783
784        let req = Request::builder()
785            .method("POST")
786            .uri(format!("/{}/retry", run.id))
787            .header("content-type", "application/json")
788            .header("authorization", format!("Bearer {}", token.0))
789            .body(Body::from("{}"))
790            .unwrap();
791
792        let resp = app.oneshot(req).await.unwrap();
793        assert_eq!(resp.status(), HttpStatusCode::CREATED);
794
795        let body = resp.into_body().collect().await.unwrap().to_bytes();
796        let json_val: JsonValue = from_slice(&body).unwrap();
797        assert_eq!(json_val["data"]["created_by"]["kind"], "user");
798        assert_eq!(
799            json_val["data"]["created_by"]["id"],
800            retrying_user.id.to_string()
801        );
802        assert_eq!(json_val["data"]["created_by"]["label"], "bob");
803    }
804
805    // ---- Idempotency-Key ----
806
807    #[tokio::test]
808    async fn retry_does_not_inherit_the_idempotency_key() {
809        let store = Arc::new(InMemoryStore::new());
810        let run = store
811            .create_run(NewRun {
812                workflow_name: "test".to_string(),
813                trigger: TriggerKind::Api,
814                payload: json!({"key": "value"}),
815                max_retries: 3,
816                handler_version: None,
817                labels: HashMap::new(),
818                scheduled_at: None,
819                created_by: None,
820                idempotency_key: Some("github:abc-123".to_string()),
821                concurrency_key: None,
822                priority: 0,
823                concurrency_limits: Vec::new(),
824                max_cost_usd: None,
825                worker_tags: Vec::new(),
826            })
827            .await
828            .unwrap()
829            .into_run();
830
831        store
832            .update_run_status(run.id, RunStatus::Running)
833            .await
834            .unwrap();
835        store
836            .update_run_status(run.id, RunStatus::Failed)
837            .await
838            .unwrap();
839
840        let state = test_state(store.clone());
841        let auth_header = create_user_auth_header(&state, "testuser", true).await;
842        let app = Router::new()
843            .route("/{id}/retry", post(retry_run))
844            .with_state(state);
845
846        let req = Request::builder()
847            .method("POST")
848            .uri(format!("/{}/retry", run.id))
849            .header("content-type", "application/json")
850            .header("authorization", auth_header)
851            .body(Body::from("{}"))
852            .unwrap();
853
854        let resp = app.oneshot(req).await.unwrap();
855        assert_eq!(resp.status(), HttpStatusCode::CREATED);
856
857        let body = resp.into_body().collect().await.unwrap().to_bytes();
858        let json_val: JsonValue = from_slice(&body).unwrap();
859        assert!(
860            json_val["data"].get("idempotency_key").is_none(),
861            "the retry run must not carry the original key"
862        );
863
864        // The original key still resolves to the original run.
865        let bound = store
866            .find_run_by_idempotency_key("github:abc-123")
867            .await
868            .unwrap()
869            .expect("key still bound");
870        assert_eq!(bound.id, run.id);
871    }
872
873    // ---- Handler version compatibility ----
874
875    struct V2Handler;
876    impl WorkflowHandler for V2Handler {
877        fn name(&self) -> &str {
878            "versioned-wf"
879        }
880        fn version(&self) -> Option<&str> {
881            Some("2.0.0")
882        }
883        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
884            Box::pin(async { Ok(()) })
885        }
886    }
887
888    struct V2CompatHandler;
889    impl WorkflowHandler for V2CompatHandler {
890        fn name(&self) -> &str {
891            "compat-wf"
892        }
893        fn version(&self) -> Option<&str> {
894            Some("2.0.0")
895        }
896        fn compatible_versions(&self) -> &[&str] {
897            &["1.0.0"]
898        }
899        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
900            Box::pin(async { Ok(()) })
901        }
902    }
903
904    fn test_state_with_handlers(store: Arc<InMemoryStore>) -> AppState {
905        let provider = Arc::new(ClaudeCodeProvider::new());
906        let mut engine = Engine::new(store.clone(), provider);
907        engine.register(V2Handler).unwrap();
908        engine.register(V2CompatHandler).unwrap();
909        let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
910            secret: "test-secret".to_string(),
911            access_token_ttl_secs: 900,
912            refresh_token_ttl_secs: 604800,
913            cookie_domain: None,
914            cookie_secure: false,
915        });
916        let (event_sender, _) = broadcast::channel::<Event>(1);
917        AppState::new(
918            store,
919            Arc::new(engine),
920            jwt_config,
921            "test-worker-token".to_string(),
922            event_sender,
923        )
924    }
925
926    async fn create_failed_run(
927        store: &Arc<InMemoryStore>,
928        workflow_name: &str,
929        handler_version: Option<&str>,
930    ) -> ironflow_store::models::Run {
931        let run = store
932            .create_run(NewRun {
933                created_by: None,
934                workflow_name: workflow_name.to_string(),
935                trigger: TriggerKind::Manual,
936                payload: json!({}),
937                max_retries: 0,
938                handler_version: handler_version.map(str::to_string),
939                labels: HashMap::new(),
940                scheduled_at: None,
941                idempotency_key: None,
942                concurrency_key: None,
943                priority: 0,
944                concurrency_limits: Vec::new(),
945                max_cost_usd: None,
946                worker_tags: Vec::new(),
947            })
948            .await
949            .unwrap()
950            .into_run();
951
952        store
953            .update_run_status(run.id, RunStatus::Running)
954            .await
955            .unwrap();
956        store
957            .update_run_status(run.id, RunStatus::Failed)
958            .await
959            .unwrap();
960        run
961    }
962
963    #[tokio::test]
964    async fn retry_same_version_succeeds() {
965        let store = Arc::new(InMemoryStore::new());
966        let run = create_failed_run(&store, "versioned-wf", Some("2.0.0")).await;
967
968        let state = test_state_with_handlers(store);
969        let auth_header = create_user_auth_header(&state, "testuser", true).await;
970        let app = Router::new()
971            .route("/{id}/retry", post(retry_run))
972            .with_state(state);
973
974        let req = Request::builder()
975            .method("POST")
976            .uri(format!("/{}/retry", run.id))
977            .header("content-type", "application/json")
978            .header("authorization", auth_header)
979            .body(Body::from("{}"))
980            .unwrap();
981
982        let resp = app.oneshot(req).await.unwrap();
983        assert_eq!(resp.status(), HttpStatusCode::CREATED);
984    }
985
986    #[tokio::test]
987    async fn retry_version_mismatch_returns_409() {
988        let store = Arc::new(InMemoryStore::new());
989        let run = create_failed_run(&store, "versioned-wf", Some("1.0.0")).await;
990
991        let state = test_state_with_handlers(store);
992        let auth_header = create_user_auth_header(&state, "testuser", true).await;
993        let app = Router::new()
994            .route("/{id}/retry", post(retry_run))
995            .with_state(state);
996
997        let req = Request::builder()
998            .method("POST")
999            .uri(format!("/{}/retry", run.id))
1000            .header("content-type", "application/json")
1001            .header("authorization", auth_header)
1002            .body(Body::from("{}"))
1003            .unwrap();
1004
1005        let resp = app.oneshot(req).await.unwrap();
1006        assert_eq!(resp.status(), HttpStatusCode::CONFLICT);
1007
1008        let body = resp.into_body().collect().await.unwrap().to_bytes();
1009        let json_val: JsonValue = from_slice(&body).unwrap();
1010        let msg = json_val["error"]["message"].as_str().unwrap();
1011        assert!(msg.contains("HANDLER_VERSION_MISMATCH"));
1012        assert!(msg.contains("2.0.0"));
1013        assert!(msg.contains("1.0.0"));
1014    }
1015
1016    #[tokio::test]
1017    async fn retry_version_mismatch_with_force_succeeds() {
1018        let store = Arc::new(InMemoryStore::new());
1019        let run = create_failed_run(&store, "versioned-wf", Some("1.0.0")).await;
1020
1021        let state = test_state_with_handlers(store.clone());
1022        let auth_header = create_user_auth_header(&state, "testuser", true).await;
1023        let app = Router::new()
1024            .route("/{id}/retry", post(retry_run))
1025            .with_state(state);
1026
1027        let req = Request::builder()
1028            .method("POST")
1029            .uri(format!("/{}/retry?force=true", run.id))
1030            .header("content-type", "application/json")
1031            .header("authorization", auth_header)
1032            .body(Body::from("{}"))
1033            .unwrap();
1034
1035        let resp = app.oneshot(req).await.unwrap();
1036        assert_eq!(resp.status(), HttpStatusCode::CREATED);
1037
1038        let body = resp.into_body().collect().await.unwrap().to_bytes();
1039        let json_val: JsonValue = from_slice(&body).unwrap();
1040        let new_id: Uuid = from_value(json_val["data"]["id"].clone()).unwrap();
1041        let new_run = store.get_run(new_id).await.unwrap().unwrap();
1042        assert_eq!(new_run.handler_version, Some("2.0.0".to_string()));
1043    }
1044
1045    #[tokio::test]
1046    async fn retry_compatible_version_succeeds() {
1047        let store = Arc::new(InMemoryStore::new());
1048        let run = create_failed_run(&store, "compat-wf", Some("1.0.0")).await;
1049
1050        let state = test_state_with_handlers(store);
1051        let auth_header = create_user_auth_header(&state, "testuser", true).await;
1052        let app = Router::new()
1053            .route("/{id}/retry", post(retry_run))
1054            .with_state(state);
1055
1056        let req = Request::builder()
1057            .method("POST")
1058            .uri(format!("/{}/retry", run.id))
1059            .header("content-type", "application/json")
1060            .header("authorization", auth_header)
1061            .body(Body::from("{}"))
1062            .unwrap();
1063
1064        let resp = app.oneshot(req).await.unwrap();
1065        assert_eq!(resp.status(), HttpStatusCode::CREATED);
1066    }
1067
1068    #[tokio::test]
1069    async fn retry_null_version_is_compatible() {
1070        let store = Arc::new(InMemoryStore::new());
1071        let run = create_failed_run(&store, "versioned-wf", None).await;
1072
1073        let state = test_state_with_handlers(store);
1074        let auth_header = create_user_auth_header(&state, "testuser", true).await;
1075        let app = Router::new()
1076            .route("/{id}/retry", post(retry_run))
1077            .with_state(state);
1078
1079        let req = Request::builder()
1080            .method("POST")
1081            .uri(format!("/{}/retry", run.id))
1082            .header("content-type", "application/json")
1083            .header("authorization", auth_header)
1084            .body(Body::from("{}"))
1085            .unwrap();
1086
1087        let resp = app.oneshot(req).await.unwrap();
1088        assert_eq!(resp.status(), HttpStatusCode::CREATED);
1089    }
1090
1091    #[tokio::test]
1092    async fn retry_writes_current_handler_version() {
1093        let store = Arc::new(InMemoryStore::new());
1094        let run = create_failed_run(&store, "versioned-wf", Some("2.0.0")).await;
1095
1096        let state = test_state_with_handlers(store.clone());
1097        let auth_header = create_user_auth_header(&state, "testuser", true).await;
1098        let app = Router::new()
1099            .route("/{id}/retry", post(retry_run))
1100            .with_state(state);
1101
1102        let req = Request::builder()
1103            .method("POST")
1104            .uri(format!("/{}/retry", run.id))
1105            .header("content-type", "application/json")
1106            .header("authorization", auth_header)
1107            .body(Body::from("{}"))
1108            .unwrap();
1109
1110        let resp = app.oneshot(req).await.unwrap();
1111        assert_eq!(resp.status(), HttpStatusCode::CREATED);
1112
1113        let body = resp.into_body().collect().await.unwrap().to_bytes();
1114        let json_val: JsonValue = from_slice(&body).unwrap();
1115        let new_id: Uuid = from_value(json_val["data"]["id"].clone()).unwrap();
1116        let new_run = store.get_run(new_id).await.unwrap().unwrap();
1117        assert_eq!(new_run.handler_version, Some("2.0.0".to_string()));
1118    }
1119    /// A cancelled run holding `key`, ready to be retried.
1120    async fn cancelled_keyed_run(store: &InMemoryStore, key: &str) -> Uuid {
1121        let run = store
1122            .create_run(NewRun {
1123                workflow_name: "test".to_string(),
1124                trigger: TriggerKind::Manual,
1125                payload: json!({}),
1126                max_retries: 0,
1127                handler_version: None,
1128                labels: HashMap::new(),
1129                scheduled_at: None,
1130                created_by: None,
1131                idempotency_key: None,
1132                concurrency_key: Some(key.to_string()),
1133                priority: 0,
1134                concurrency_limits: Vec::new(),
1135                max_cost_usd: None,
1136                worker_tags: Vec::new(),
1137            })
1138            .await
1139            .unwrap()
1140            .into_run();
1141        store
1142            .update_run_status(run.id, RunStatus::Cancelled)
1143            .await
1144            .unwrap();
1145        run.id
1146    }
1147
1148    async fn post_retry(state: AppState, run_id: Uuid) -> Response {
1149        let auth_header = create_user_auth_header(&state, "testuser", true).await;
1150        let app = Router::new()
1151            .route("/{id}/retry", post(retry_run))
1152            .with_state(state);
1153        let req = Request::builder()
1154            .method("POST")
1155            .uri(format!("/{run_id}/retry"))
1156            .header("content-type", "application/json")
1157            .header("authorization", auth_header)
1158            .body(Body::from("{}"))
1159            .unwrap();
1160        app.oneshot(req).await.unwrap()
1161    }
1162
1163    fn test_state_with_quota(store: Arc<InMemoryStore>, budget: BudgetConfig) -> AppState {
1164        let provider = Arc::new(ClaudeCodeProvider::new());
1165        let engine = Engine::new(store.clone(), provider).with_budget_config(budget);
1166        let jwt_config = Arc::new(JwtConfig {
1167            secret: "test-secret".to_string(),
1168            access_token_ttl_secs: 900,
1169            refresh_token_ttl_secs: 604800,
1170            cookie_domain: None,
1171            cookie_secure: false,
1172        });
1173        let (event_sender, _) = broadcast::channel::<Event>(1);
1174        AppState::new(
1175            store,
1176            Arc::new(engine),
1177            jwt_config,
1178            "test-worker-token".to_string(),
1179            event_sender,
1180        )
1181    }
1182
1183    #[tokio::test]
1184    async fn retry_returns_429_when_monthly_quota_exhausted() {
1185        let store = Arc::new(InMemoryStore::new());
1186        let run_id = create_failed_run(&store, "test", None).await.id;
1187
1188        let budget = BudgetConfig::new().monthly_cost_limit_usd(Decimal::ZERO);
1189        let resp = post_retry(test_state_with_quota(store.clone(), budget), run_id).await;
1190        assert_eq!(resp.status(), HttpStatusCode::TOO_MANY_REQUESTS);
1191
1192        let body = resp.into_body().collect().await.unwrap().to_bytes();
1193        let json_val: JsonValue = from_slice(&body).unwrap();
1194        assert_eq!(json_val["error"]["code"], "MONTHLY_BUDGET_EXCEEDED");
1195
1196        let runs = store.list_runs(RunFilter::default(), 1, 50).await.unwrap();
1197        assert_eq!(runs.total, 1);
1198    }
1199
1200    #[tokio::test]
1201    async fn retry_succeeds_when_monthly_quota_has_room() {
1202        let store = Arc::new(InMemoryStore::new());
1203        let run_id = create_failed_run(&store, "test", None).await.id;
1204
1205        let budget = BudgetConfig::new().monthly_cost_limit_usd(Decimal::new(10000, 2));
1206        let resp = post_retry(test_state_with_quota(store, budget), run_id).await;
1207        assert_eq!(resp.status(), HttpStatusCode::CREATED);
1208    }
1209
1210    #[tokio::test]
1211    async fn retry_with_exhausted_quota_still_returns_400_for_a_running_run() {
1212        let store = Arc::new(InMemoryStore::new());
1213        let running = store
1214            .create_run(NewRun {
1215                created_by: None,
1216                workflow_name: "test".to_string(),
1217                trigger: TriggerKind::Manual,
1218                payload: json!({}),
1219                max_retries: 0,
1220                handler_version: None,
1221                labels: HashMap::new(),
1222                scheduled_at: None,
1223                idempotency_key: None,
1224                concurrency_key: None,
1225                priority: 0,
1226                concurrency_limits: Vec::new(),
1227                max_cost_usd: None,
1228                worker_tags: Vec::new(),
1229            })
1230            .await
1231            .unwrap()
1232            .into_run();
1233        store
1234            .update_run_status(running.id, RunStatus::Running)
1235            .await
1236            .unwrap();
1237        let budget = BudgetConfig::new().monthly_cost_limit_usd(Decimal::ZERO);
1238        let resp = post_retry(test_state_with_quota(store, budget), running.id).await;
1239        assert_eq!(resp.status(), HttpStatusCode::BAD_REQUEST);
1240    }
1241
1242    #[tokio::test]
1243    async fn retry_inherits_the_original_concurrency_key() {
1244        let store = Arc::new(InMemoryStore::new());
1245        let original = cancelled_keyed_run(&store, "issue:12").await;
1246
1247        let resp = post_retry(test_state(store.clone()), original).await;
1248        assert_eq!(resp.status(), HttpStatusCode::CREATED);
1249
1250        let body = resp.into_body().collect().await.unwrap().to_bytes();
1251        let json_val: JsonValue = from_slice(&body).unwrap();
1252        assert_eq!(json_val["data"]["concurrency_key"], "issue:12");
1253        let new_id: Uuid = from_value(json_val["data"]["id"].clone()).unwrap();
1254        let new_run = store.get_run(new_id).await.unwrap().unwrap();
1255        assert_eq!(new_run.concurrency_key.as_deref(), Some("issue:12"));
1256    }
1257
1258    #[tokio::test]
1259    async fn retry_returns_409_when_the_concurrency_key_was_taken_since() {
1260        let store = Arc::new(InMemoryStore::new());
1261        let original = cancelled_keyed_run(&store, "issue:12").await;
1262        let holder = store
1263            .create_run(NewRun {
1264                workflow_name: "test".to_string(),
1265                trigger: TriggerKind::Manual,
1266                payload: json!({}),
1267                max_retries: 0,
1268                handler_version: None,
1269                labels: HashMap::new(),
1270                scheduled_at: None,
1271                created_by: None,
1272                idempotency_key: None,
1273                concurrency_key: Some("issue:12".to_string()),
1274                priority: 0,
1275                concurrency_limits: Vec::new(),
1276                max_cost_usd: None,
1277                worker_tags: Vec::new(),
1278            })
1279            .await
1280            .unwrap()
1281            .into_run();
1282
1283        let resp = post_retry(test_state(store.clone()), original).await;
1284        assert_eq!(resp.status(), HttpStatusCode::CONFLICT);
1285
1286        let body = resp.into_body().collect().await.unwrap().to_bytes();
1287        let json_val: JsonValue = from_slice(&body).unwrap();
1288        assert_eq!(json_val["error"]["code"], "CONCURRENCY_CONFLICT");
1289        assert_eq!(json_val["error"]["details"]["run_id"], json!(holder.id));
1290    }
1291}