1use ff_core::error::ErrorClass;
8
9#[cfg(feature = "valkey-client")]
10use crate::retry::is_retryable_kind;
11
12#[derive(Debug, thiserror::Error)]
20#[non_exhaustive]
21pub enum ScriptError {
22 #[error("stale_lease: lease superseded by reclaim")]
25 StaleLease,
26
27 #[error("lease_expired: lease TTL elapsed")]
29 LeaseExpired,
30
31 #[error("lease_revoked: operator revoked lease")]
33 LeaseRevoked,
34
35 #[error(
44 "execution_not_active: lifecycle_phase={lifecycle_phase} terminal_outcome={terminal_outcome} lease_epoch={lease_epoch} attempt_id={attempt_id}"
45 )]
46 ExecutionNotActive {
47 terminal_outcome: String,
48 lease_epoch: String,
49 lifecycle_phase: String,
50 attempt_id: String,
51 },
52
53 #[error("no_active_lease: target has no active lease")]
55 NoActiveLease,
56
57 #[error("fence_required: lease fence triple is mandatory for this FCALL")]
66 FenceRequired,
67
68 #[error("partial_fence_triple: lease_id/lease_epoch/attempt_id must be all set or all empty")]
73 PartialFenceTriple,
74
75 #[error("active_attempt_exists: invariant violation")]
77 ActiveAttemptExists,
78
79 #[error("use_claim_resumed_execution: attempt_interrupted, use resume claim path")]
82 UseClaimResumedExecution,
83
84 #[error("not_a_resumed_execution: use normal claim path")]
86 NotAResumedExecution,
87
88 #[error("execution_not_leaseable: state changed since grant")]
90 ExecutionNotLeaseable,
91
92 #[error("lease_conflict: another worker holds lease")]
94 LeaseConflict,
95
96 #[error("invalid_claim_grant: grant missing or mismatched")]
98 InvalidClaimGrant,
99
100 #[error("claim_grant_expired: grant TTL elapsed")]
102 ClaimGrantExpired,
103
104 #[error("no_eligible_execution: no execution available")]
106 NoEligibleExecution,
107
108 #[error("budget_exceeded: hard budget limit reached")]
111 BudgetExceeded,
112
113 #[error("budget_soft_exceeded: soft budget limit reached")]
115 BudgetSoftExceeded,
116
117 #[error("execution_not_suspended: already resumed or cancelled")]
120 ExecutionNotSuspended,
121
122 #[error("already_suspended: suspension already active")]
124 AlreadySuspended,
125
126 #[error("waitpoint_closed: waitpoint already closed")]
128 WaitpointClosed,
129
130 #[error("waitpoint_not_found: waitpoint does not exist yet")]
132 WaitpointNotFound,
133
134 #[error("target_not_signalable: no valid signal target")]
136 TargetNotSignalable,
137
138 #[error("waitpoint_pending_use_buffer_script: route to buffer script")]
140 WaitpointPendingUseBufferScript,
141
142 #[error("duplicate_signal: signal already delivered")]
144 DuplicateSignal,
145
146 #[error("payload_too_large: signal payload exceeds 64KB")]
148 PayloadTooLarge,
149
150 #[error("signal_limit_exceeded: max signals per execution reached")]
152 SignalLimitExceeded,
153
154 #[error("invalid_waitpoint_key: MAC verification failed")]
156 InvalidWaitpointKey,
157
158 #[error("invalid_lease_for_suspend: lease/attempt binding mismatch")]
160 InvalidLeaseForSuspend,
161
162 #[error("resume_condition_not_met: resume conditions not satisfied")]
164 ResumeConditionNotMet,
165
166 #[error("waitpoint_not_pending: waitpoint is not in pending state")]
168 WaitpointNotPending,
169
170 #[error("pending_waitpoint_expired: pending waitpoint aged out")]
172 PendingWaitpointExpired,
173
174 #[error("invalid_waitpoint_for_execution: waitpoint does not belong to execution")]
176 InvalidWaitpointForExecution,
177
178 #[error("waitpoint_already_exists: waitpoint already exists")]
180 WaitpointAlreadyExists,
181
182 #[error("waitpoint_not_open: waitpoint is not pending or active")]
184 WaitpointNotOpen,
185
186 #[error("execution_not_terminal: cannot replay non-terminal execution")]
189 ExecutionNotTerminal,
190
191 #[error("max_replays_exhausted: replay limit reached")]
193 MaxReplaysExhausted,
194
195 #[error("stream_closed: attempt terminal, no appends allowed")]
198 StreamClosed,
199
200 #[error("stale_owner_cannot_append: lease mismatch on append")]
202 StaleOwnerCannotAppend,
203
204 #[error("retention_limit_exceeded: frame exceeds size limit")]
206 RetentionLimitExceeded,
207
208 #[error("execution_not_eligible: state changed")]
211 ExecutionNotEligible,
212
213 #[error("execution_not_in_eligible_set: removed by another scheduler")]
215 ExecutionNotInEligibleSet,
216
217 #[error("grant_already_exists: grant already active")]
219 GrantAlreadyExists,
220
221 #[error("execution_not_reclaimable: already reclaimed or cancelled")]
223 ExecutionNotReclaimable,
224
225 #[error("invalid_dependency: dependency edge not found")]
228 InvalidDependency,
229
230 #[error("stale_graph_revision: graph has been updated")]
232 StaleGraphRevision,
233
234 #[error("execution_already_in_flow: execution belongs to another flow")]
236 ExecutionAlreadyInFlow,
237
238 #[error("cycle_detected: dependency edge would create cycle")]
240 CycleDetected,
241
242 #[error("flow_not_found: flow does not exist")]
244 FlowNotFound,
245
246 #[error("execution_not_in_flow: execution not in flow")]
248 ExecutionNotInFlow,
249
250 #[error("dependency_already_exists: edge already exists")]
252 DependencyAlreadyExists,
253
254 #[error("self_referencing_edge: upstream and downstream are the same")]
256 SelfReferencingEdge,
257
258 #[error("flow_already_terminal: flow is already terminal")]
260 FlowAlreadyTerminal,
261
262 #[error("deps_not_satisfied: dependencies still unresolved")]
264 DepsNotSatisfied,
265
266 #[error("not_blocked_by_deps: execution not blocked by dependencies")]
268 NotBlockedByDeps,
269
270 #[error("not_runnable: execution is not in runnable state")]
272 NotRunnable,
273
274 #[error("terminal: execution is already terminal")]
276 Terminal,
277
278 #[error("invalid_blocking_reason: unrecognized blocking reason")]
280 InvalidBlockingReason,
281
282 #[error("ok_already_applied: usage seq already processed")]
285 OkAlreadyApplied,
286
287 #[error("attempt_not_found: attempt index does not exist")]
290 AttemptNotFound,
291
292 #[error("attempt_not_in_created_state: internal sequencing error")]
294 AttemptNotInCreatedState,
295
296 #[error("attempt_not_started: attempt not in started state")]
298 AttemptNotStarted,
299
300 #[error("attempt_already_terminal: attempt already ended")]
302 AttemptAlreadyTerminal,
303
304 #[error("execution_not_found: execution does not exist")]
306 ExecutionNotFound,
307
308 #[error("execution_not_eligible_for_attempt: wrong state for new attempt")]
310 ExecutionNotEligibleForAttempt,
311
312 #[error("replay_not_allowed: execution not terminal or limit reached")]
314 ReplayNotAllowed,
315
316 #[error("max_retries_exhausted: retry limit reached")]
318 MaxRetriesExhausted,
319
320 #[error("stream_not_found: no frames appended yet")]
323 StreamNotFound,
324
325 #[error("stream_already_closed: stream already closed")]
327 StreamAlreadyClosed,
328
329 #[error("invalid_frame_type: unrecognized frame type")]
331 InvalidFrameType,
332
333 #[error("invalid_offset: invalid stream ID offset")]
335 InvalidOffset,
336
337 #[error("unauthorized: authentication/authorization failed")]
339 Unauthorized,
340
341 #[error("budget_not_found: budget does not exist")]
344 BudgetNotFound,
345
346 #[error("invalid_budget_scope: malformed budget scope")]
348 InvalidBudgetScope,
349
350 #[error("budget_attach_conflict: budget attachment conflict")]
352 BudgetAttachConflict,
353
354 #[error("budget_override_not_allowed: insufficient privileges")]
356 BudgetOverrideNotAllowed,
357
358 #[error("quota_policy_not_found: quota policy does not exist")]
360 QuotaPolicyNotFound,
361
362 #[error("rate_limit_exceeded: rate limit window full")]
364 RateLimitExceeded,
365
366 #[error("concurrency_limit_exceeded: concurrency cap reached")]
368 ConcurrencyLimitExceeded,
369
370 #[error("quota_attach_conflict: quota policy already attached")]
372 QuotaAttachConflict,
373
374 #[error("invalid_quota_spec: malformed quota policy definition")]
376 InvalidQuotaSpec,
377
378 #[error("invalid_input: {0}")]
380 InvalidInput(String),
381
382 #[error("capability_mismatch: missing {0}")]
386 CapabilityMismatch(String),
387
388 #[error("invalid_capabilities: {0}")]
392 InvalidCapabilities(String),
393
394 #[error("invalid_policy_json: {0}")]
401 InvalidPolicyJson(String),
402
403 #[error("waitpoint_not_token_bound")]
411 WaitpointNotTokenBound,
412
413 #[error("invalid_kid: kid must be non-empty and contain no ':'")]
415 InvalidKid,
416
417 #[error("invalid_secret_hex: secret must be a non-empty even-length hex string")]
419 InvalidSecretHex,
420
421 #[error("invalid_grace_ms: grace_ms must be a non-negative integer")]
423 InvalidGraceMs,
424
425 #[error("rotation_conflict: kid {0} already installed with a different secret")]
429 RotationConflict(String),
430
431 #[error("invalid_tag_key: {0}")]
437 InvalidTagKey(String),
438
439 #[cfg(feature = "valkey-client")]
447 #[error("valkey: {0}")]
448 Valkey(#[from] ferriskey::Error),
449
450 #[error("{}", fmt_parse(.fcall, .execution_id.as_deref(), .message))]
457 Parse {
458 fcall: String,
459 execution_id: Option<String>,
460 message: String,
461 },
462}
463
464fn fmt_parse(fcall: &str, execution_id: Option<&str>, message: &str) -> String {
468 match execution_id {
469 Some(eid) => format!("parse error: {fcall}[exec={eid}]: {message}"),
470 None => format!("parse error: {fcall}: {message}"),
471 }
472}
473
474impl ScriptError {
475 #[cfg(feature = "valkey-client")]
479 pub fn valkey_kind(&self) -> Option<ferriskey::ErrorKind> {
480 match self {
481 Self::Valkey(e) => Some(e.kind()),
482 _ => None,
483 }
484 }
485
486 pub fn class(&self) -> ErrorClass {
488 match self {
489 Self::StaleLease
491 | Self::LeaseExpired
492 | Self::LeaseRevoked
493 | Self::ExecutionNotActive { .. }
494 | Self::TargetNotSignalable
495 | Self::PayloadTooLarge
496 | Self::SignalLimitExceeded
497 | Self::InvalidWaitpointKey
498 | Self::ExecutionNotTerminal
499 | Self::MaxReplaysExhausted
500 | Self::StreamClosed
501 | Self::StaleOwnerCannotAppend
502 | Self::RetentionLimitExceeded
503 | Self::InvalidLeaseForSuspend
504 | Self::ResumeConditionNotMet
505 | Self::InvalidDependency
506 | Self::ExecutionAlreadyInFlow
507 | Self::CycleDetected
508 | Self::FlowNotFound
509 | Self::ExecutionNotInFlow
510 | Self::DependencyAlreadyExists
511 | Self::SelfReferencingEdge
512 | Self::FlowAlreadyTerminal
513 | Self::InvalidWaitpointForExecution
514 | Self::InvalidBlockingReason
515 | Self::NotRunnable
516 | Self::Terminal
517 | Self::AttemptNotFound
518 | Self::AttemptNotStarted
519 | Self::ExecutionNotFound
520 | Self::ExecutionNotEligibleForAttempt
521 | Self::ReplayNotAllowed
522 | Self::MaxRetriesExhausted
523 | Self::Unauthorized
524 | Self::BudgetNotFound
525 | Self::InvalidBudgetScope
526 | Self::BudgetAttachConflict
527 | Self::BudgetOverrideNotAllowed
528 | Self::QuotaPolicyNotFound
529 | Self::QuotaAttachConflict
530 | Self::InvalidQuotaSpec
531 | Self::InvalidInput(_)
532 | Self::InvalidCapabilities(_)
533 | Self::InvalidPolicyJson(_)
534 | Self::WaitpointNotTokenBound
535 | Self::InvalidKid
536 | Self::InvalidSecretHex
537 | Self::InvalidGraceMs
538 | Self::RotationConflict(_)
539 | Self::InvalidTagKey(_)
540 | Self::FenceRequired
541 | Self::PartialFenceTriple
542 | Self::Parse { .. } => ErrorClass::Terminal,
543
544 #[cfg(feature = "valkey-client")]
549 Self::Valkey(e) => {
550 if is_retryable_kind(e.kind()) {
551 ErrorClass::Retryable
552 } else {
553 ErrorClass::Terminal
554 }
555 }
556
557 Self::UseClaimResumedExecution
559 | Self::NotAResumedExecution
560 | Self::ExecutionNotLeaseable
561 | Self::LeaseConflict
562 | Self::InvalidClaimGrant
563 | Self::ClaimGrantExpired
564 | Self::NoEligibleExecution
565 | Self::WaitpointNotFound
566 | Self::WaitpointPendingUseBufferScript
567 | Self::StaleGraphRevision
568 | Self::RateLimitExceeded
569 | Self::ConcurrencyLimitExceeded
570 | Self::CapabilityMismatch(_)
571 | Self::InvalidOffset => ErrorClass::Retryable,
572
573 Self::BudgetExceeded => ErrorClass::Cooperative,
575
576 Self::ExecutionNotSuspended
578 | Self::AlreadySuspended
579 | Self::WaitpointClosed
580 | Self::DuplicateSignal
581 | Self::ExecutionNotEligible
582 | Self::ExecutionNotInEligibleSet
583 | Self::GrantAlreadyExists
584 | Self::ExecutionNotReclaimable
585 | Self::NoActiveLease
586 | Self::OkAlreadyApplied
587 | Self::AttemptAlreadyTerminal
588 | Self::StreamAlreadyClosed
589 | Self::BudgetSoftExceeded
590 | Self::WaitpointAlreadyExists
591 | Self::WaitpointNotOpen
592 | Self::WaitpointNotPending
593 | Self::PendingWaitpointExpired
594 | Self::NotBlockedByDeps
595 | Self::DepsNotSatisfied => ErrorClass::Informational,
596
597 Self::ActiveAttemptExists | Self::AttemptNotInCreatedState => ErrorClass::Bug,
599
600 Self::StreamNotFound => ErrorClass::Expected,
602
603 Self::InvalidFrameType => ErrorClass::SoftError,
605 }
606 }
607
608 pub fn from_code(code: &str) -> Option<Self> {
610 Some(match code {
611 "stale_lease" => Self::StaleLease,
612 "lease_expired" => Self::LeaseExpired,
613 "lease_revoked" => Self::LeaseRevoked,
614 "execution_not_active" => Self::ExecutionNotActive {
615 terminal_outcome: String::new(),
616 lease_epoch: String::new(),
617 lifecycle_phase: String::new(),
618 attempt_id: String::new(),
619 },
620 "no_active_lease" => Self::NoActiveLease,
621 "active_attempt_exists" => Self::ActiveAttemptExists,
622 "use_claim_resumed_execution" => Self::UseClaimResumedExecution,
623 "not_a_resumed_execution" => Self::NotAResumedExecution,
624 "execution_not_leaseable" => Self::ExecutionNotLeaseable,
625 "lease_conflict" => Self::LeaseConflict,
626 "invalid_claim_grant" => Self::InvalidClaimGrant,
627 "claim_grant_expired" => Self::ClaimGrantExpired,
628 "no_eligible_execution" => Self::NoEligibleExecution,
629 "budget_exceeded" => Self::BudgetExceeded,
630 "budget_soft_exceeded" => Self::BudgetSoftExceeded,
631 "execution_not_suspended" => Self::ExecutionNotSuspended,
632 "already_suspended" => Self::AlreadySuspended,
633 "waitpoint_closed" => Self::WaitpointClosed,
634 "waitpoint_not_found" => Self::WaitpointNotFound,
635 "target_not_signalable" => Self::TargetNotSignalable,
636 "waitpoint_pending_use_buffer_script" => Self::WaitpointPendingUseBufferScript,
637 "duplicate_signal" => Self::DuplicateSignal,
638 "payload_too_large" => Self::PayloadTooLarge,
639 "signal_limit_exceeded" => Self::SignalLimitExceeded,
640 "invalid_waitpoint_key" => Self::InvalidWaitpointKey,
641 "invalid_lease_for_suspend" => Self::InvalidLeaseForSuspend,
642 "resume_condition_not_met" => Self::ResumeConditionNotMet,
643 "waitpoint_not_pending" => Self::WaitpointNotPending,
644 "pending_waitpoint_expired" => Self::PendingWaitpointExpired,
645 "invalid_waitpoint_for_execution" => Self::InvalidWaitpointForExecution,
646 "waitpoint_already_exists" => Self::WaitpointAlreadyExists,
647 "waitpoint_not_open" => Self::WaitpointNotOpen,
648 "execution_not_terminal" => Self::ExecutionNotTerminal,
649 "max_replays_exhausted" => Self::MaxReplaysExhausted,
650 "stream_closed" => Self::StreamClosed,
651 "stale_owner_cannot_append" => Self::StaleOwnerCannotAppend,
652 "retention_limit_exceeded" => Self::RetentionLimitExceeded,
653 "execution_not_eligible" => Self::ExecutionNotEligible,
654 "execution_not_in_eligible_set" => Self::ExecutionNotInEligibleSet,
655 "grant_already_exists" => Self::GrantAlreadyExists,
656 "execution_not_reclaimable" => Self::ExecutionNotReclaimable,
657 "invalid_dependency" => Self::InvalidDependency,
658 "stale_graph_revision" => Self::StaleGraphRevision,
659 "execution_already_in_flow" => Self::ExecutionAlreadyInFlow,
660 "cycle_detected" => Self::CycleDetected,
661 "flow_not_found" => Self::FlowNotFound,
662 "execution_not_in_flow" => Self::ExecutionNotInFlow,
663 "dependency_already_exists" => Self::DependencyAlreadyExists,
664 "self_referencing_edge" => Self::SelfReferencingEdge,
665 "flow_already_terminal" => Self::FlowAlreadyTerminal,
666 "deps_not_satisfied" => Self::DepsNotSatisfied,
667 "not_blocked_by_deps" => Self::NotBlockedByDeps,
668 "not_runnable" => Self::NotRunnable,
669 "terminal" => Self::Terminal,
670 "invalid_blocking_reason" => Self::InvalidBlockingReason,
671 "ok_already_applied" => Self::OkAlreadyApplied,
672 "attempt_not_found" => Self::AttemptNotFound,
673 "attempt_not_in_created_state" => Self::AttemptNotInCreatedState,
674 "attempt_not_started" => Self::AttemptNotStarted,
675 "attempt_already_terminal" => Self::AttemptAlreadyTerminal,
676 "execution_not_found" => Self::ExecutionNotFound,
677 "execution_not_eligible_for_attempt" => Self::ExecutionNotEligibleForAttempt,
678 "replay_not_allowed" => Self::ReplayNotAllowed,
679 "max_retries_exhausted" => Self::MaxRetriesExhausted,
680 "stream_not_found" => Self::StreamNotFound,
681 "stream_already_closed" => Self::StreamAlreadyClosed,
682 "invalid_frame_type" => Self::InvalidFrameType,
683 "invalid_offset" => Self::InvalidOffset,
684 "unauthorized" => Self::Unauthorized,
685 "budget_not_found" => Self::BudgetNotFound,
686 "invalid_budget_scope" => Self::InvalidBudgetScope,
687 "budget_attach_conflict" => Self::BudgetAttachConflict,
688 "budget_override_not_allowed" => Self::BudgetOverrideNotAllowed,
689 "quota_policy_not_found" => Self::QuotaPolicyNotFound,
690 "rate_limit_exceeded" => Self::RateLimitExceeded,
691 "concurrency_limit_exceeded" => Self::ConcurrencyLimitExceeded,
692 "quota_attach_conflict" => Self::QuotaAttachConflict,
693 "invalid_quota_spec" => Self::InvalidQuotaSpec,
694 "invalid_input" => Self::InvalidInput(String::new()),
695 "capability_mismatch" => Self::CapabilityMismatch(String::new()),
696 "invalid_capabilities" => Self::InvalidCapabilities(String::new()),
697 "invalid_policy_json" => Self::InvalidPolicyJson(String::new()),
698 "waitpoint_not_token_bound" => Self::WaitpointNotTokenBound,
699 "invalid_kid" => Self::InvalidKid,
700 "invalid_secret_hex" => Self::InvalidSecretHex,
701 "invalid_grace_ms" => Self::InvalidGraceMs,
702 "rotation_conflict" => Self::RotationConflict(String::new()),
703 "invalid_tag_key" => Self::InvalidTagKey(String::new()),
704 "fence_required" => Self::FenceRequired,
705 "partial_fence_triple" => Self::PartialFenceTriple,
706 _ => return None,
707 })
708 }
709
710 pub fn from_code_with_detail(code: &str, detail: &str) -> Option<Self> {
718 Self::from_code_with_details(code, std::slice::from_ref(&detail))
719 }
720
721 pub fn from_code_with_details(code: &str, details: &[&str]) -> Option<Self> {
727 let base = Self::from_code(code)?;
728 let d = |i: usize| details.get(i).copied().unwrap_or("").to_owned();
729 Some(match base {
730 Self::CapabilityMismatch(_) => Self::CapabilityMismatch(d(0)),
731 Self::InvalidCapabilities(_) => Self::InvalidCapabilities(d(0)),
732 Self::InvalidPolicyJson(_) => Self::InvalidPolicyJson(d(0)),
733 Self::InvalidInput(_) => Self::InvalidInput(d(0)),
734 Self::RotationConflict(_) => Self::RotationConflict(d(0)),
735 Self::InvalidTagKey(_) => Self::InvalidTagKey(d(0)),
736 Self::ExecutionNotActive { .. } => Self::ExecutionNotActive {
737 terminal_outcome: d(0),
738 lease_epoch: d(1),
739 lifecycle_phase: d(2),
740 attempt_id: d(3),
741 },
742 other => other,
743 })
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 #[test]
752 fn error_classification_terminal() {
753 assert_eq!(ScriptError::StaleLease.class(), ErrorClass::Terminal);
754 assert_eq!(ScriptError::LeaseExpired.class(), ErrorClass::Terminal);
755 assert_eq!(ScriptError::ExecutionNotFound.class(), ErrorClass::Terminal);
756 }
757
758 #[test]
759 fn error_classification_retryable() {
760 assert_eq!(
761 ScriptError::UseClaimResumedExecution.class(),
762 ErrorClass::Retryable
763 );
764 assert_eq!(
765 ScriptError::NoEligibleExecution.class(),
766 ErrorClass::Retryable
767 );
768 assert_eq!(
769 ScriptError::WaitpointNotFound.class(),
770 ErrorClass::Retryable
771 );
772 assert_eq!(
773 ScriptError::RateLimitExceeded.class(),
774 ErrorClass::Retryable
775 );
776 }
777
778 #[test]
779 fn error_classification_cooperative() {
780 assert_eq!(ScriptError::BudgetExceeded.class(), ErrorClass::Cooperative);
781 }
782
783 #[cfg(feature = "valkey-client")]
784 #[test]
785 fn error_classification_valkey_transient_is_retryable() {
786 use ferriskey::ErrorKind;
787 let transient = ScriptError::Valkey(ferriskey::Error::from((
788 ErrorKind::IoError,
789 "connection dropped",
790 )));
791 assert_eq!(transient.class(), ErrorClass::Retryable);
792 }
793
794 #[cfg(feature = "valkey-client")]
795 #[test]
796 fn error_classification_valkey_permanent_is_terminal() {
797 use ferriskey::ErrorKind;
798 let permanent = ScriptError::Valkey(ferriskey::Error::from((
799 ErrorKind::AuthenticationFailed,
800 "bad creds",
801 )));
802 assert_eq!(permanent.class(), ErrorClass::Terminal);
803
804 let fatal_recv = ScriptError::Valkey(ferriskey::Error::from((
807 ErrorKind::FatalReceiveError,
808 "response lost",
809 )));
810 assert_eq!(fatal_recv.class(), ErrorClass::Terminal);
811 }
812
813 #[test]
814 fn error_classification_informational() {
815 assert_eq!(
816 ScriptError::ExecutionNotSuspended.class(),
817 ErrorClass::Informational
818 );
819 assert_eq!(
820 ScriptError::DuplicateSignal.class(),
821 ErrorClass::Informational
822 );
823 assert_eq!(
824 ScriptError::OkAlreadyApplied.class(),
825 ErrorClass::Informational
826 );
827 }
828
829 #[test]
830 fn error_classification_bug() {
831 assert_eq!(ScriptError::ActiveAttemptExists.class(), ErrorClass::Bug);
832 assert_eq!(
833 ScriptError::AttemptNotInCreatedState.class(),
834 ErrorClass::Bug
835 );
836 }
837
838 #[test]
839 fn error_classification_expected() {
840 assert_eq!(ScriptError::StreamNotFound.class(), ErrorClass::Expected);
841 }
842
843 #[test]
844 fn error_classification_budget_soft_exceeded() {
845 assert_eq!(
847 ScriptError::BudgetSoftExceeded.class(),
848 ErrorClass::Informational
849 );
850 }
851
852 #[test]
853 fn error_classification_soft_error() {
854 assert_eq!(ScriptError::InvalidFrameType.class(), ErrorClass::SoftError);
855 }
856
857 #[test]
858 fn from_code_roundtrip() {
859 let codes = [
860 "stale_lease", "lease_expired", "lease_revoked",
861 "execution_not_active", "no_active_lease", "active_attempt_exists",
862 "use_claim_resumed_execution", "not_a_resumed_execution",
863 "execution_not_leaseable", "lease_conflict",
864 "invalid_claim_grant", "claim_grant_expired",
865 "budget_exceeded", "budget_soft_exceeded",
866 "execution_not_suspended", "already_suspended",
867 "waitpoint_closed", "waitpoint_not_found",
868 "target_not_signalable", "waitpoint_pending_use_buffer_script",
869 "invalid_lease_for_suspend", "resume_condition_not_met",
870 "signal_limit_exceeded",
871 "execution_not_terminal", "max_replays_exhausted",
872 "stream_closed", "stale_owner_cannot_append", "retention_limit_exceeded",
873 "execution_not_eligible", "execution_not_in_eligible_set",
874 "grant_already_exists", "execution_not_reclaimable",
875 "invalid_dependency", "stale_graph_revision",
876 "execution_already_in_flow", "cycle_detected",
877 "execution_not_found", "max_retries_exhausted",
878 "flow_not_found", "execution_not_in_flow",
879 "dependency_already_exists", "self_referencing_edge",
880 "flow_already_terminal",
881 "deps_not_satisfied", "not_blocked_by_deps",
882 "not_runnable", "terminal", "invalid_blocking_reason",
883 "waitpoint_not_pending", "pending_waitpoint_expired",
884 "invalid_waitpoint_for_execution", "waitpoint_already_exists",
885 "waitpoint_not_open",
886 ];
887 for code in codes {
888 let err = ScriptError::from_code(code);
889 assert!(err.is_some(), "failed to parse code: {code}");
890 }
891 }
892
893 #[test]
894 fn from_code_unknown_returns_none() {
895 assert!(ScriptError::from_code("nonexistent_error").is_none());
896 }
897
898 #[test]
899 fn fence_required_classifies_terminal() {
900 assert_eq!(ScriptError::FenceRequired.class(), ErrorClass::Terminal);
901 assert_eq!(
902 ScriptError::PartialFenceTriple.class(),
903 ErrorClass::Terminal
904 );
905 }
906
907 #[test]
908 fn fence_required_from_code_roundtrips() {
909 assert!(matches!(
910 ScriptError::from_code("fence_required"),
911 Some(ScriptError::FenceRequired)
912 ));
913 assert!(matches!(
914 ScriptError::from_code("partial_fence_triple"),
915 Some(ScriptError::PartialFenceTriple)
916 ));
917 }
918
919 #[test]
925 fn parse_structured_fields_render_and_match() {
926 let with_exec = ScriptError::Parse {
927 fcall: "ff_claim_execution".into(),
928 execution_id: Some("018f-abc".into()),
929 message: "expected Array".into(),
930 };
931 assert_eq!(
932 with_exec.to_string(),
933 "parse error: ff_claim_execution[exec=018f-abc]: expected Array"
934 );
935 assert!(matches!(
936 &with_exec,
937 ScriptError::Parse { execution_id: Some(e), .. } if e == "018f-abc"
938 ));
939
940 let no_exec = ScriptError::Parse {
941 fcall: "stream_tail_decode".into(),
942 execution_id: None,
943 message: "unexpected array length 3".into(),
944 };
945 assert_eq!(
946 no_exec.to_string(),
947 "parse error: stream_tail_decode: unexpected array length 3"
948 );
949 assert!(matches!(
950 &no_exec,
951 ScriptError::Parse { execution_id: None, fcall, .. } if !fcall.is_empty()
952 ));
953 }
954}