1use ff_core::error::ErrorClass;
8
9use crate::retry::is_retryable_kind;
10
11#[derive(Debug, thiserror::Error)]
19pub enum ScriptError {
20 #[error("stale_lease: lease superseded by reclaim")]
23 StaleLease,
24
25 #[error("lease_expired: lease TTL elapsed")]
27 LeaseExpired,
28
29 #[error("lease_revoked: operator revoked lease")]
31 LeaseRevoked,
32
33 #[error("execution_not_active: execution is not in active state")]
35 ExecutionNotActive,
36
37 #[error("no_active_lease: target has no active lease")]
39 NoActiveLease,
40
41 #[error("active_attempt_exists: invariant violation")]
43 ActiveAttemptExists,
44
45 #[error("use_claim_resumed_execution: attempt_interrupted, use resume claim path")]
48 UseClaimResumedExecution,
49
50 #[error("not_a_resumed_execution: use normal claim path")]
52 NotAResumedExecution,
53
54 #[error("execution_not_leaseable: state changed since grant")]
56 ExecutionNotLeaseable,
57
58 #[error("lease_conflict: another worker holds lease")]
60 LeaseConflict,
61
62 #[error("invalid_claim_grant: grant missing or mismatched")]
64 InvalidClaimGrant,
65
66 #[error("claim_grant_expired: grant TTL elapsed")]
68 ClaimGrantExpired,
69
70 #[error("no_eligible_execution: no execution available")]
72 NoEligibleExecution,
73
74 #[error("budget_exceeded: hard budget limit reached")]
77 BudgetExceeded,
78
79 #[error("budget_soft_exceeded: soft budget limit reached")]
81 BudgetSoftExceeded,
82
83 #[error("execution_not_suspended: already resumed or cancelled")]
86 ExecutionNotSuspended,
87
88 #[error("already_suspended: suspension already active")]
90 AlreadySuspended,
91
92 #[error("waitpoint_closed: waitpoint already closed")]
94 WaitpointClosed,
95
96 #[error("waitpoint_not_found: waitpoint does not exist yet")]
98 WaitpointNotFound,
99
100 #[error("target_not_signalable: no valid signal target")]
102 TargetNotSignalable,
103
104 #[error("waitpoint_pending_use_buffer_script: route to buffer script")]
106 WaitpointPendingUseBufferScript,
107
108 #[error("duplicate_signal: signal already delivered")]
110 DuplicateSignal,
111
112 #[error("payload_too_large: signal payload exceeds 64KB")]
114 PayloadTooLarge,
115
116 #[error("signal_limit_exceeded: max signals per execution reached")]
118 SignalLimitExceeded,
119
120 #[error("invalid_waitpoint_key: MAC verification failed")]
122 InvalidWaitpointKey,
123
124 #[error("invalid_lease_for_suspend: lease/attempt binding mismatch")]
126 InvalidLeaseForSuspend,
127
128 #[error("resume_condition_not_met: resume conditions not satisfied")]
130 ResumeConditionNotMet,
131
132 #[error("waitpoint_not_pending: waitpoint is not in pending state")]
134 WaitpointNotPending,
135
136 #[error("pending_waitpoint_expired: pending waitpoint aged out")]
138 PendingWaitpointExpired,
139
140 #[error("invalid_waitpoint_for_execution: waitpoint does not belong to execution")]
142 InvalidWaitpointForExecution,
143
144 #[error("waitpoint_already_exists: waitpoint already exists")]
146 WaitpointAlreadyExists,
147
148 #[error("waitpoint_not_open: waitpoint is not pending or active")]
150 WaitpointNotOpen,
151
152 #[error("execution_not_terminal: cannot replay non-terminal execution")]
155 ExecutionNotTerminal,
156
157 #[error("max_replays_exhausted: replay limit reached")]
159 MaxReplaysExhausted,
160
161 #[error("stream_closed: attempt terminal, no appends allowed")]
164 StreamClosed,
165
166 #[error("stale_owner_cannot_append: lease mismatch on append")]
168 StaleOwnerCannotAppend,
169
170 #[error("retention_limit_exceeded: frame exceeds size limit")]
172 RetentionLimitExceeded,
173
174 #[error("execution_not_eligible: state changed")]
177 ExecutionNotEligible,
178
179 #[error("execution_not_in_eligible_set: removed by another scheduler")]
181 ExecutionNotInEligibleSet,
182
183 #[error("grant_already_exists: grant already active")]
185 GrantAlreadyExists,
186
187 #[error("execution_not_reclaimable: already reclaimed or cancelled")]
189 ExecutionNotReclaimable,
190
191 #[error("invalid_dependency: dependency edge not found")]
194 InvalidDependency,
195
196 #[error("stale_graph_revision: graph has been updated")]
198 StaleGraphRevision,
199
200 #[error("execution_already_in_flow: execution belongs to another flow")]
202 ExecutionAlreadyInFlow,
203
204 #[error("cycle_detected: dependency edge would create cycle")]
206 CycleDetected,
207
208 #[error("flow_not_found: flow does not exist")]
210 FlowNotFound,
211
212 #[error("execution_not_in_flow: execution not in flow")]
214 ExecutionNotInFlow,
215
216 #[error("dependency_already_exists: edge already exists")]
218 DependencyAlreadyExists,
219
220 #[error("self_referencing_edge: upstream and downstream are the same")]
222 SelfReferencingEdge,
223
224 #[error("flow_already_terminal: flow is already terminal")]
226 FlowAlreadyTerminal,
227
228 #[error("deps_not_satisfied: dependencies still unresolved")]
230 DepsNotSatisfied,
231
232 #[error("not_blocked_by_deps: execution not blocked by dependencies")]
234 NotBlockedByDeps,
235
236 #[error("not_runnable: execution is not in runnable state")]
238 NotRunnable,
239
240 #[error("terminal: execution is already terminal")]
242 Terminal,
243
244 #[error("invalid_blocking_reason: unrecognized blocking reason")]
246 InvalidBlockingReason,
247
248 #[error("ok_already_applied: usage seq already processed")]
251 OkAlreadyApplied,
252
253 #[error("attempt_not_found: attempt index does not exist")]
256 AttemptNotFound,
257
258 #[error("attempt_not_in_created_state: internal sequencing error")]
260 AttemptNotInCreatedState,
261
262 #[error("attempt_not_started: attempt not in started state")]
264 AttemptNotStarted,
265
266 #[error("attempt_already_terminal: attempt already ended")]
268 AttemptAlreadyTerminal,
269
270 #[error("execution_not_found: execution does not exist")]
272 ExecutionNotFound,
273
274 #[error("execution_not_eligible_for_attempt: wrong state for new attempt")]
276 ExecutionNotEligibleForAttempt,
277
278 #[error("replay_not_allowed: execution not terminal or limit reached")]
280 ReplayNotAllowed,
281
282 #[error("max_retries_exhausted: retry limit reached")]
284 MaxRetriesExhausted,
285
286 #[error("stream_not_found: no frames appended yet")]
289 StreamNotFound,
290
291 #[error("stream_already_closed: stream already closed")]
293 StreamAlreadyClosed,
294
295 #[error("invalid_frame_type: unrecognized frame type")]
297 InvalidFrameType,
298
299 #[error("invalid_offset: invalid stream ID offset")]
301 InvalidOffset,
302
303 #[error("unauthorized: authentication/authorization failed")]
305 Unauthorized,
306
307 #[error("budget_not_found: budget does not exist")]
310 BudgetNotFound,
311
312 #[error("invalid_budget_scope: malformed budget scope")]
314 InvalidBudgetScope,
315
316 #[error("budget_attach_conflict: budget attachment conflict")]
318 BudgetAttachConflict,
319
320 #[error("budget_override_not_allowed: insufficient privileges")]
322 BudgetOverrideNotAllowed,
323
324 #[error("quota_policy_not_found: quota policy does not exist")]
326 QuotaPolicyNotFound,
327
328 #[error("rate_limit_exceeded: rate limit window full")]
330 RateLimitExceeded,
331
332 #[error("concurrency_limit_exceeded: concurrency cap reached")]
334 ConcurrencyLimitExceeded,
335
336 #[error("quota_attach_conflict: quota policy already attached")]
338 QuotaAttachConflict,
339
340 #[error("invalid_quota_spec: malformed quota policy definition")]
342 InvalidQuotaSpec,
343
344 #[error("invalid_input: {0}")]
346 InvalidInput(String),
347
348 #[error("capability_mismatch: missing {0}")]
352 CapabilityMismatch(String),
353
354 #[error("invalid_capabilities: {0}")]
358 InvalidCapabilities(String),
359
360 #[error("invalid_policy_json: {0}")]
367 InvalidPolicyJson(String),
368
369 #[error("waitpoint_not_token_bound")]
377 WaitpointNotTokenBound,
378
379 #[error("valkey: {0}")]
383 Valkey(#[from] ferriskey::Error),
384
385 #[error("parse error: {0}")]
387 Parse(String),
388}
389
390impl ScriptError {
391 pub fn valkey_kind(&self) -> Option<ferriskey::ErrorKind> {
393 match self {
394 Self::Valkey(e) => Some(e.kind()),
395 _ => None,
396 }
397 }
398
399 pub fn class(&self) -> ErrorClass {
401 match self {
402 Self::StaleLease
404 | Self::LeaseExpired
405 | Self::LeaseRevoked
406 | Self::ExecutionNotActive
407 | Self::TargetNotSignalable
408 | Self::PayloadTooLarge
409 | Self::SignalLimitExceeded
410 | Self::InvalidWaitpointKey
411 | Self::ExecutionNotTerminal
412 | Self::MaxReplaysExhausted
413 | Self::StreamClosed
414 | Self::StaleOwnerCannotAppend
415 | Self::RetentionLimitExceeded
416 | Self::InvalidLeaseForSuspend
417 | Self::ResumeConditionNotMet
418 | Self::InvalidDependency
419 | Self::ExecutionAlreadyInFlow
420 | Self::CycleDetected
421 | Self::FlowNotFound
422 | Self::ExecutionNotInFlow
423 | Self::DependencyAlreadyExists
424 | Self::SelfReferencingEdge
425 | Self::FlowAlreadyTerminal
426 | Self::InvalidWaitpointForExecution
427 | Self::InvalidBlockingReason
428 | Self::NotRunnable
429 | Self::Terminal
430 | Self::AttemptNotFound
431 | Self::AttemptNotStarted
432 | Self::ExecutionNotFound
433 | Self::ExecutionNotEligibleForAttempt
434 | Self::ReplayNotAllowed
435 | Self::MaxRetriesExhausted
436 | Self::Unauthorized
437 | Self::BudgetNotFound
438 | Self::InvalidBudgetScope
439 | Self::BudgetAttachConflict
440 | Self::BudgetOverrideNotAllowed
441 | Self::QuotaPolicyNotFound
442 | Self::QuotaAttachConflict
443 | Self::InvalidQuotaSpec
444 | Self::InvalidInput(_)
445 | Self::InvalidCapabilities(_)
446 | Self::InvalidPolicyJson(_)
447 | Self::WaitpointNotTokenBound
448 | Self::Parse(_) => ErrorClass::Terminal,
449
450 Self::Valkey(e) => {
455 if is_retryable_kind(e.kind()) {
456 ErrorClass::Retryable
457 } else {
458 ErrorClass::Terminal
459 }
460 }
461
462 Self::UseClaimResumedExecution
464 | Self::NotAResumedExecution
465 | Self::ExecutionNotLeaseable
466 | Self::LeaseConflict
467 | Self::InvalidClaimGrant
468 | Self::ClaimGrantExpired
469 | Self::NoEligibleExecution
470 | Self::WaitpointNotFound
471 | Self::WaitpointPendingUseBufferScript
472 | Self::StaleGraphRevision
473 | Self::RateLimitExceeded
474 | Self::ConcurrencyLimitExceeded
475 | Self::CapabilityMismatch(_)
476 | Self::InvalidOffset => ErrorClass::Retryable,
477
478 Self::BudgetExceeded => ErrorClass::Cooperative,
480
481 Self::ExecutionNotSuspended
483 | Self::AlreadySuspended
484 | Self::WaitpointClosed
485 | Self::DuplicateSignal
486 | Self::ExecutionNotEligible
487 | Self::ExecutionNotInEligibleSet
488 | Self::GrantAlreadyExists
489 | Self::ExecutionNotReclaimable
490 | Self::NoActiveLease
491 | Self::OkAlreadyApplied
492 | Self::AttemptAlreadyTerminal
493 | Self::StreamAlreadyClosed
494 | Self::BudgetSoftExceeded
495 | Self::WaitpointAlreadyExists
496 | Self::WaitpointNotOpen
497 | Self::WaitpointNotPending
498 | Self::PendingWaitpointExpired
499 | Self::NotBlockedByDeps
500 | Self::DepsNotSatisfied => ErrorClass::Informational,
501
502 Self::ActiveAttemptExists | Self::AttemptNotInCreatedState => ErrorClass::Bug,
504
505 Self::StreamNotFound => ErrorClass::Expected,
507
508 Self::InvalidFrameType => ErrorClass::SoftError,
510 }
511 }
512
513 pub fn from_code(code: &str) -> Option<Self> {
515 Some(match code {
516 "stale_lease" => Self::StaleLease,
517 "lease_expired" => Self::LeaseExpired,
518 "lease_revoked" => Self::LeaseRevoked,
519 "execution_not_active" => Self::ExecutionNotActive,
520 "no_active_lease" => Self::NoActiveLease,
521 "active_attempt_exists" => Self::ActiveAttemptExists,
522 "use_claim_resumed_execution" => Self::UseClaimResumedExecution,
523 "not_a_resumed_execution" => Self::NotAResumedExecution,
524 "execution_not_leaseable" => Self::ExecutionNotLeaseable,
525 "lease_conflict" => Self::LeaseConflict,
526 "invalid_claim_grant" => Self::InvalidClaimGrant,
527 "claim_grant_expired" => Self::ClaimGrantExpired,
528 "no_eligible_execution" => Self::NoEligibleExecution,
529 "budget_exceeded" => Self::BudgetExceeded,
530 "budget_soft_exceeded" => Self::BudgetSoftExceeded,
531 "execution_not_suspended" => Self::ExecutionNotSuspended,
532 "already_suspended" => Self::AlreadySuspended,
533 "waitpoint_closed" => Self::WaitpointClosed,
534 "waitpoint_not_found" => Self::WaitpointNotFound,
535 "target_not_signalable" => Self::TargetNotSignalable,
536 "waitpoint_pending_use_buffer_script" => Self::WaitpointPendingUseBufferScript,
537 "duplicate_signal" => Self::DuplicateSignal,
538 "payload_too_large" => Self::PayloadTooLarge,
539 "signal_limit_exceeded" => Self::SignalLimitExceeded,
540 "invalid_waitpoint_key" => Self::InvalidWaitpointKey,
541 "invalid_lease_for_suspend" => Self::InvalidLeaseForSuspend,
542 "resume_condition_not_met" => Self::ResumeConditionNotMet,
543 "waitpoint_not_pending" => Self::WaitpointNotPending,
544 "pending_waitpoint_expired" => Self::PendingWaitpointExpired,
545 "invalid_waitpoint_for_execution" => Self::InvalidWaitpointForExecution,
546 "waitpoint_already_exists" => Self::WaitpointAlreadyExists,
547 "waitpoint_not_open" => Self::WaitpointNotOpen,
548 "execution_not_terminal" => Self::ExecutionNotTerminal,
549 "max_replays_exhausted" => Self::MaxReplaysExhausted,
550 "stream_closed" => Self::StreamClosed,
551 "stale_owner_cannot_append" => Self::StaleOwnerCannotAppend,
552 "retention_limit_exceeded" => Self::RetentionLimitExceeded,
553 "execution_not_eligible" => Self::ExecutionNotEligible,
554 "execution_not_in_eligible_set" => Self::ExecutionNotInEligibleSet,
555 "grant_already_exists" => Self::GrantAlreadyExists,
556 "execution_not_reclaimable" => Self::ExecutionNotReclaimable,
557 "invalid_dependency" => Self::InvalidDependency,
558 "stale_graph_revision" => Self::StaleGraphRevision,
559 "execution_already_in_flow" => Self::ExecutionAlreadyInFlow,
560 "cycle_detected" => Self::CycleDetected,
561 "flow_not_found" => Self::FlowNotFound,
562 "execution_not_in_flow" => Self::ExecutionNotInFlow,
563 "dependency_already_exists" => Self::DependencyAlreadyExists,
564 "self_referencing_edge" => Self::SelfReferencingEdge,
565 "flow_already_terminal" => Self::FlowAlreadyTerminal,
566 "deps_not_satisfied" => Self::DepsNotSatisfied,
567 "not_blocked_by_deps" => Self::NotBlockedByDeps,
568 "not_runnable" => Self::NotRunnable,
569 "terminal" => Self::Terminal,
570 "invalid_blocking_reason" => Self::InvalidBlockingReason,
571 "ok_already_applied" => Self::OkAlreadyApplied,
572 "attempt_not_found" => Self::AttemptNotFound,
573 "attempt_not_in_created_state" => Self::AttemptNotInCreatedState,
574 "attempt_not_started" => Self::AttemptNotStarted,
575 "attempt_already_terminal" => Self::AttemptAlreadyTerminal,
576 "execution_not_found" => Self::ExecutionNotFound,
577 "execution_not_eligible_for_attempt" => Self::ExecutionNotEligibleForAttempt,
578 "replay_not_allowed" => Self::ReplayNotAllowed,
579 "max_retries_exhausted" => Self::MaxRetriesExhausted,
580 "stream_not_found" => Self::StreamNotFound,
581 "stream_already_closed" => Self::StreamAlreadyClosed,
582 "invalid_frame_type" => Self::InvalidFrameType,
583 "invalid_offset" => Self::InvalidOffset,
584 "unauthorized" => Self::Unauthorized,
585 "budget_not_found" => Self::BudgetNotFound,
586 "invalid_budget_scope" => Self::InvalidBudgetScope,
587 "budget_attach_conflict" => Self::BudgetAttachConflict,
588 "budget_override_not_allowed" => Self::BudgetOverrideNotAllowed,
589 "quota_policy_not_found" => Self::QuotaPolicyNotFound,
590 "rate_limit_exceeded" => Self::RateLimitExceeded,
591 "concurrency_limit_exceeded" => Self::ConcurrencyLimitExceeded,
592 "quota_attach_conflict" => Self::QuotaAttachConflict,
593 "invalid_quota_spec" => Self::InvalidQuotaSpec,
594 "invalid_input" => Self::InvalidInput(String::new()),
595 "capability_mismatch" => Self::CapabilityMismatch(String::new()),
596 "invalid_capabilities" => Self::InvalidCapabilities(String::new()),
597 "invalid_policy_json" => Self::InvalidPolicyJson(String::new()),
598 "waitpoint_not_token_bound" => Self::WaitpointNotTokenBound,
599 _ => return None,
600 })
601 }
602
603 pub fn from_code_with_detail(code: &str, detail: &str) -> Option<Self> {
611 let base = Self::from_code(code)?;
612 Some(match base {
613 Self::CapabilityMismatch(_) => Self::CapabilityMismatch(detail.to_owned()),
614 Self::InvalidCapabilities(_) => Self::InvalidCapabilities(detail.to_owned()),
615 Self::InvalidPolicyJson(_) => Self::InvalidPolicyJson(detail.to_owned()),
616 Self::InvalidInput(_) => Self::InvalidInput(detail.to_owned()),
617 other => other,
618 })
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn error_classification_terminal() {
628 assert_eq!(ScriptError::StaleLease.class(), ErrorClass::Terminal);
629 assert_eq!(ScriptError::LeaseExpired.class(), ErrorClass::Terminal);
630 assert_eq!(ScriptError::ExecutionNotFound.class(), ErrorClass::Terminal);
631 }
632
633 #[test]
634 fn error_classification_retryable() {
635 assert_eq!(
636 ScriptError::UseClaimResumedExecution.class(),
637 ErrorClass::Retryable
638 );
639 assert_eq!(
640 ScriptError::NoEligibleExecution.class(),
641 ErrorClass::Retryable
642 );
643 assert_eq!(
644 ScriptError::WaitpointNotFound.class(),
645 ErrorClass::Retryable
646 );
647 assert_eq!(
648 ScriptError::RateLimitExceeded.class(),
649 ErrorClass::Retryable
650 );
651 }
652
653 #[test]
654 fn error_classification_cooperative() {
655 assert_eq!(ScriptError::BudgetExceeded.class(), ErrorClass::Cooperative);
656 }
657
658 #[test]
659 fn error_classification_valkey_transient_is_retryable() {
660 use ferriskey::ErrorKind;
661 let transient = ScriptError::Valkey(ferriskey::Error::from((
662 ErrorKind::IoError,
663 "connection dropped",
664 )));
665 assert_eq!(transient.class(), ErrorClass::Retryable);
666 }
667
668 #[test]
669 fn error_classification_valkey_permanent_is_terminal() {
670 use ferriskey::ErrorKind;
671 let permanent = ScriptError::Valkey(ferriskey::Error::from((
672 ErrorKind::AuthenticationFailed,
673 "bad creds",
674 )));
675 assert_eq!(permanent.class(), ErrorClass::Terminal);
676
677 let fatal_recv = ScriptError::Valkey(ferriskey::Error::from((
680 ErrorKind::FatalReceiveError,
681 "response lost",
682 )));
683 assert_eq!(fatal_recv.class(), ErrorClass::Terminal);
684 }
685
686 #[test]
687 fn error_classification_informational() {
688 assert_eq!(
689 ScriptError::ExecutionNotSuspended.class(),
690 ErrorClass::Informational
691 );
692 assert_eq!(
693 ScriptError::DuplicateSignal.class(),
694 ErrorClass::Informational
695 );
696 assert_eq!(
697 ScriptError::OkAlreadyApplied.class(),
698 ErrorClass::Informational
699 );
700 }
701
702 #[test]
703 fn error_classification_bug() {
704 assert_eq!(ScriptError::ActiveAttemptExists.class(), ErrorClass::Bug);
705 assert_eq!(
706 ScriptError::AttemptNotInCreatedState.class(),
707 ErrorClass::Bug
708 );
709 }
710
711 #[test]
712 fn error_classification_expected() {
713 assert_eq!(ScriptError::StreamNotFound.class(), ErrorClass::Expected);
714 }
715
716 #[test]
717 fn error_classification_budget_soft_exceeded() {
718 assert_eq!(
720 ScriptError::BudgetSoftExceeded.class(),
721 ErrorClass::Informational
722 );
723 }
724
725 #[test]
726 fn error_classification_soft_error() {
727 assert_eq!(ScriptError::InvalidFrameType.class(), ErrorClass::SoftError);
728 }
729
730 #[test]
731 fn from_code_roundtrip() {
732 let codes = [
733 "stale_lease", "lease_expired", "lease_revoked",
734 "execution_not_active", "no_active_lease", "active_attempt_exists",
735 "use_claim_resumed_execution", "not_a_resumed_execution",
736 "execution_not_leaseable", "lease_conflict",
737 "invalid_claim_grant", "claim_grant_expired",
738 "budget_exceeded", "budget_soft_exceeded",
739 "execution_not_suspended", "already_suspended",
740 "waitpoint_closed", "waitpoint_not_found",
741 "target_not_signalable", "waitpoint_pending_use_buffer_script",
742 "invalid_lease_for_suspend", "resume_condition_not_met",
743 "signal_limit_exceeded",
744 "execution_not_terminal", "max_replays_exhausted",
745 "stream_closed", "stale_owner_cannot_append", "retention_limit_exceeded",
746 "execution_not_eligible", "execution_not_in_eligible_set",
747 "grant_already_exists", "execution_not_reclaimable",
748 "invalid_dependency", "stale_graph_revision",
749 "execution_already_in_flow", "cycle_detected",
750 "execution_not_found", "max_retries_exhausted",
751 "flow_not_found", "execution_not_in_flow",
752 "dependency_already_exists", "self_referencing_edge",
753 "flow_already_terminal",
754 "deps_not_satisfied", "not_blocked_by_deps",
755 "not_runnable", "terminal", "invalid_blocking_reason",
756 "waitpoint_not_pending", "pending_waitpoint_expired",
757 "invalid_waitpoint_for_execution", "waitpoint_already_exists",
758 "waitpoint_not_open",
759 ];
760 for code in codes {
761 let err = ScriptError::from_code(code);
762 assert!(err.is_some(), "failed to parse code: {code}");
763 }
764 }
765
766 #[test]
767 fn from_code_unknown_returns_none() {
768 assert!(ScriptError::from_code("nonexistent_error").is_none());
769 }
770}