1use 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#[derive(Debug, Deserialize, Default)]
24pub struct RetryQuery {
25 #[serde(default)]
27 pub force: bool,
28}
29
30#[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 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 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 created_by: Some(run_actor_of(&auth)),
138 idempotency_key: None,
141 concurrency_key: original.concurrency_key,
145 priority: original.priority,
147 concurrency_limits: original.concurrency_limits,
149 max_cost_usd: original.max_cost_usd,
151 worker_tags: original.worker_tags,
153 })
154 .await?
155 .into_run();
156
157 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 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 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 #[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 #[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 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 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 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}