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")]
157 InvalidWaitpointKey,
158
159 #[error("invalid_token: waitpoint_token HMAC verification failed")]
164 InvalidToken,
165
166 #[error("invalid_lease_for_suspend: lease/attempt binding mismatch")]
168 InvalidLeaseForSuspend,
169
170 #[error("resume_condition_not_met: resume conditions not satisfied")]
172 ResumeConditionNotMet,
173
174 #[error("waitpoint_not_pending: waitpoint is not in pending state")]
176 WaitpointNotPending,
177
178 #[error("pending_waitpoint_expired: pending waitpoint aged out")]
180 PendingWaitpointExpired,
181
182 #[error("invalid_waitpoint_for_execution: waitpoint does not belong to execution")]
184 InvalidWaitpointForExecution,
185
186 #[error("waitpoint_already_exists: waitpoint already exists")]
188 WaitpointAlreadyExists,
189
190 #[error("waitpoint_not_open: waitpoint is not pending or active")]
192 WaitpointNotOpen,
193
194 #[error("execution_not_terminal: cannot replay non-terminal execution")]
197 ExecutionNotTerminal,
198
199 #[error("max_replays_exhausted: replay limit reached")]
201 MaxReplaysExhausted,
202
203 #[error("stream_closed: attempt terminal, no appends allowed")]
206 StreamClosed,
207
208 #[error("stale_owner_cannot_append: lease mismatch on append")]
210 StaleOwnerCannotAppend,
211
212 #[error("retention_limit_exceeded: frame exceeds size limit")]
214 RetentionLimitExceeded,
215
216 #[error("execution_not_eligible: state changed")]
219 ExecutionNotEligible,
220
221 #[error("execution_not_in_eligible_set: removed by another scheduler")]
223 ExecutionNotInEligibleSet,
224
225 #[error("grant_already_exists: grant already active")]
227 GrantAlreadyExists,
228
229 #[error("execution_not_reclaimable: already reclaimed or cancelled")]
231 ExecutionNotReclaimable,
232
233 #[error("invalid_dependency: dependency edge not found")]
236 InvalidDependency,
237
238 #[error("stale_graph_revision: graph has been updated")]
240 StaleGraphRevision,
241
242 #[error("execution_already_in_flow: execution belongs to another flow")]
244 ExecutionAlreadyInFlow,
245
246 #[error("cycle_detected: dependency edge would create cycle")]
248 CycleDetected,
249
250 #[error("flow_not_found: flow does not exist")]
252 FlowNotFound,
253
254 #[error("execution_not_in_flow: execution not in flow")]
256 ExecutionNotInFlow,
257
258 #[error("dependency_already_exists: edge already exists")]
260 DependencyAlreadyExists,
261
262 #[error("self_referencing_edge: upstream and downstream are the same")]
264 SelfReferencingEdge,
265
266 #[error("flow_already_terminal: flow is already terminal")]
268 FlowAlreadyTerminal,
269
270 #[error("deps_not_satisfied: dependencies still unresolved")]
272 DepsNotSatisfied,
273
274 #[error("not_blocked_by_deps: execution not blocked by dependencies")]
276 NotBlockedByDeps,
277
278 #[error("not_runnable: execution is not in runnable state")]
280 NotRunnable,
281
282 #[error("terminal: execution is already terminal")]
284 Terminal,
285
286 #[error("invalid_blocking_reason: unrecognized blocking reason")]
288 InvalidBlockingReason,
289
290 #[error("ok_already_applied: usage seq already processed")]
293 OkAlreadyApplied,
294
295 #[error("attempt_not_found: attempt index does not exist")]
298 AttemptNotFound,
299
300 #[error("attempt_not_in_created_state: internal sequencing error")]
302 AttemptNotInCreatedState,
303
304 #[error("attempt_not_started: attempt not in started state")]
306 AttemptNotStarted,
307
308 #[error("attempt_already_terminal: attempt already ended")]
310 AttemptAlreadyTerminal,
311
312 #[error("execution_not_found: execution does not exist")]
314 ExecutionNotFound,
315
316 #[error("execution_not_eligible_for_attempt: wrong state for new attempt")]
318 ExecutionNotEligibleForAttempt,
319
320 #[error("replay_not_allowed: execution not terminal or limit reached")]
322 ReplayNotAllowed,
323
324 #[error("max_retries_exhausted: retry limit reached")]
326 MaxRetriesExhausted,
327
328 #[error("stream_not_found: no frames appended yet")]
331 StreamNotFound,
332
333 #[error("stream_already_closed: stream already closed")]
335 StreamAlreadyClosed,
336
337 #[error("invalid_frame_type: unrecognized frame type")]
339 InvalidFrameType,
340
341 #[error("invalid_offset: invalid stream ID offset")]
343 InvalidOffset,
344
345 #[error("unauthorized: authentication/authorization failed")]
347 Unauthorized,
348
349 #[error("budget_not_found: budget does not exist")]
352 BudgetNotFound,
353
354 #[error("invalid_budget_scope: malformed budget scope")]
356 InvalidBudgetScope,
357
358 #[error("budget_attach_conflict: budget attachment conflict")]
360 BudgetAttachConflict,
361
362 #[error("budget_override_not_allowed: insufficient privileges")]
364 BudgetOverrideNotAllowed,
365
366 #[error("quota_policy_not_found: quota policy does not exist")]
368 QuotaPolicyNotFound,
369
370 #[error("rate_limit_exceeded: rate limit window full")]
372 RateLimitExceeded,
373
374 #[error("concurrency_limit_exceeded: concurrency cap reached")]
376 ConcurrencyLimitExceeded,
377
378 #[error("quota_attach_conflict: quota policy already attached")]
380 QuotaAttachConflict,
381
382 #[error("invalid_quota_spec: malformed quota policy definition")]
384 InvalidQuotaSpec,
385
386 #[error("invalid_input: {0}")]
388 InvalidInput(String),
389
390 #[error("capability_mismatch: missing {0}")]
394 CapabilityMismatch(String),
395
396 #[error("invalid_capabilities: {0}")]
400 InvalidCapabilities(String),
401
402 #[error("invalid_policy_json: {0}")]
409 InvalidPolicyJson(String),
410
411 #[error("waitpoint_not_token_bound")]
419 WaitpointNotTokenBound,
420
421 #[error("invalid_kid: kid must be non-empty and contain no ':'")]
423 InvalidKid,
424
425 #[error("invalid_secret_hex: secret must be a non-empty even-length hex string")]
427 InvalidSecretHex,
428
429 #[error("invalid_grace_ms: grace_ms must be a non-negative integer")]
431 InvalidGraceMs,
432
433 #[error("rotation_conflict: kid {0} already installed with a different secret")]
437 RotationConflict(String),
438
439 #[error("invalid_tag_key: {0}")]
445 InvalidTagKey(String),
446
447 #[cfg(feature = "valkey-client")]
455 #[error("valkey: {0}")]
456 Valkey(#[from] ferriskey::Error),
457
458 #[error("{}", fmt_parse(.fcall, .execution_id.as_deref(), .message))]
465 Parse {
466 fcall: String,
467 execution_id: Option<String>,
468 message: String,
469 },
470}
471
472fn fmt_parse(fcall: &str, execution_id: Option<&str>, message: &str) -> String {
476 match execution_id {
477 Some(eid) => format!("parse error: {fcall}[exec={eid}]: {message}"),
478 None => format!("parse error: {fcall}: {message}"),
479 }
480}
481
482impl ScriptError {
483 #[cfg(feature = "valkey-client")]
487 pub fn valkey_kind(&self) -> Option<ferriskey::ErrorKind> {
488 match self {
489 Self::Valkey(e) => Some(e.kind()),
490 _ => None,
491 }
492 }
493
494 pub fn class(&self) -> ErrorClass {
496 match self {
497 Self::StaleLease
499 | Self::LeaseExpired
500 | Self::LeaseRevoked
501 | Self::ExecutionNotActive { .. }
502 | Self::TargetNotSignalable
503 | Self::PayloadTooLarge
504 | Self::SignalLimitExceeded
505 | Self::InvalidWaitpointKey
506 | Self::InvalidToken
507 | Self::ExecutionNotTerminal
508 | Self::MaxReplaysExhausted
509 | Self::StreamClosed
510 | Self::StaleOwnerCannotAppend
511 | Self::RetentionLimitExceeded
512 | Self::InvalidLeaseForSuspend
513 | Self::ResumeConditionNotMet
514 | Self::InvalidDependency
515 | Self::ExecutionAlreadyInFlow
516 | Self::CycleDetected
517 | Self::FlowNotFound
518 | Self::ExecutionNotInFlow
519 | Self::DependencyAlreadyExists
520 | Self::SelfReferencingEdge
521 | Self::FlowAlreadyTerminal
522 | Self::InvalidWaitpointForExecution
523 | Self::InvalidBlockingReason
524 | Self::NotRunnable
525 | Self::Terminal
526 | Self::AttemptNotFound
527 | Self::AttemptNotStarted
528 | Self::ExecutionNotFound
529 | Self::ExecutionNotEligibleForAttempt
530 | Self::ReplayNotAllowed
531 | Self::MaxRetriesExhausted
532 | Self::Unauthorized
533 | Self::BudgetNotFound
534 | Self::InvalidBudgetScope
535 | Self::BudgetAttachConflict
536 | Self::BudgetOverrideNotAllowed
537 | Self::QuotaPolicyNotFound
538 | Self::QuotaAttachConflict
539 | Self::InvalidQuotaSpec
540 | Self::InvalidInput(_)
541 | Self::InvalidCapabilities(_)
542 | Self::InvalidPolicyJson(_)
543 | Self::WaitpointNotTokenBound
544 | Self::InvalidKid
545 | Self::InvalidSecretHex
546 | Self::InvalidGraceMs
547 | Self::RotationConflict(_)
548 | Self::InvalidTagKey(_)
549 | Self::FenceRequired
550 | Self::PartialFenceTriple
551 | Self::Parse { .. } => ErrorClass::Terminal,
552
553 #[cfg(feature = "valkey-client")]
558 Self::Valkey(e) => {
559 if is_retryable_kind(e.kind()) {
560 ErrorClass::Retryable
561 } else {
562 ErrorClass::Terminal
563 }
564 }
565
566 Self::UseClaimResumedExecution
568 | Self::NotAResumedExecution
569 | Self::ExecutionNotLeaseable
570 | Self::LeaseConflict
571 | Self::InvalidClaimGrant
572 | Self::ClaimGrantExpired
573 | Self::NoEligibleExecution
574 | Self::WaitpointNotFound
575 | Self::WaitpointPendingUseBufferScript
576 | Self::StaleGraphRevision
577 | Self::RateLimitExceeded
578 | Self::ConcurrencyLimitExceeded
579 | Self::CapabilityMismatch(_)
580 | Self::InvalidOffset => ErrorClass::Retryable,
581
582 Self::BudgetExceeded => ErrorClass::Cooperative,
584
585 Self::ExecutionNotSuspended
587 | Self::AlreadySuspended
588 | Self::WaitpointClosed
589 | Self::DuplicateSignal
590 | Self::ExecutionNotEligible
591 | Self::ExecutionNotInEligibleSet
592 | Self::GrantAlreadyExists
593 | Self::ExecutionNotReclaimable
594 | Self::NoActiveLease
595 | Self::OkAlreadyApplied
596 | Self::AttemptAlreadyTerminal
597 | Self::StreamAlreadyClosed
598 | Self::BudgetSoftExceeded
599 | Self::WaitpointAlreadyExists
600 | Self::WaitpointNotOpen
601 | Self::WaitpointNotPending
602 | Self::PendingWaitpointExpired
603 | Self::NotBlockedByDeps
604 | Self::DepsNotSatisfied => ErrorClass::Informational,
605
606 Self::ActiveAttemptExists | Self::AttemptNotInCreatedState => ErrorClass::Bug,
608
609 Self::StreamNotFound => ErrorClass::Expected,
611
612 Self::InvalidFrameType => ErrorClass::SoftError,
614 }
615 }
616
617 pub fn from_code(code: &str) -> Option<Self> {
619 Some(match code {
620 "stale_lease" => Self::StaleLease,
621 "lease_expired" => Self::LeaseExpired,
622 "lease_revoked" => Self::LeaseRevoked,
623 "execution_not_active" => Self::ExecutionNotActive {
624 terminal_outcome: String::new(),
625 lease_epoch: String::new(),
626 lifecycle_phase: String::new(),
627 attempt_id: String::new(),
628 },
629 "no_active_lease" => Self::NoActiveLease,
630 "active_attempt_exists" => Self::ActiveAttemptExists,
631 "use_claim_resumed_execution" => Self::UseClaimResumedExecution,
632 "not_a_resumed_execution" => Self::NotAResumedExecution,
633 "execution_not_leaseable" => Self::ExecutionNotLeaseable,
634 "lease_conflict" => Self::LeaseConflict,
635 "invalid_claim_grant" => Self::InvalidClaimGrant,
636 "claim_grant_expired" => Self::ClaimGrantExpired,
637 "no_eligible_execution" => Self::NoEligibleExecution,
638 "budget_exceeded" => Self::BudgetExceeded,
639 "budget_soft_exceeded" => Self::BudgetSoftExceeded,
640 "execution_not_suspended" => Self::ExecutionNotSuspended,
641 "already_suspended" => Self::AlreadySuspended,
642 "waitpoint_closed" => Self::WaitpointClosed,
643 "waitpoint_not_found" => Self::WaitpointNotFound,
644 "target_not_signalable" => Self::TargetNotSignalable,
645 "waitpoint_pending_use_buffer_script" => Self::WaitpointPendingUseBufferScript,
646 "duplicate_signal" => Self::DuplicateSignal,
647 "payload_too_large" => Self::PayloadTooLarge,
648 "signal_limit_exceeded" => Self::SignalLimitExceeded,
649 "invalid_waitpoint_key" => Self::InvalidWaitpointKey,
650 "invalid_token" => Self::InvalidToken,
651 "invalid_lease_for_suspend" => Self::InvalidLeaseForSuspend,
652 "resume_condition_not_met" => Self::ResumeConditionNotMet,
653 "waitpoint_not_pending" => Self::WaitpointNotPending,
654 "pending_waitpoint_expired" => Self::PendingWaitpointExpired,
655 "invalid_waitpoint_for_execution" => Self::InvalidWaitpointForExecution,
656 "waitpoint_already_exists" => Self::WaitpointAlreadyExists,
657 "waitpoint_not_open" => Self::WaitpointNotOpen,
658 "execution_not_terminal" => Self::ExecutionNotTerminal,
659 "max_replays_exhausted" => Self::MaxReplaysExhausted,
660 "stream_closed" => Self::StreamClosed,
661 "stale_owner_cannot_append" => Self::StaleOwnerCannotAppend,
662 "retention_limit_exceeded" => Self::RetentionLimitExceeded,
663 "execution_not_eligible" => Self::ExecutionNotEligible,
664 "execution_not_in_eligible_set" => Self::ExecutionNotInEligibleSet,
665 "grant_already_exists" => Self::GrantAlreadyExists,
666 "execution_not_reclaimable" => Self::ExecutionNotReclaimable,
667 "invalid_dependency" => Self::InvalidDependency,
668 "stale_graph_revision" => Self::StaleGraphRevision,
669 "execution_already_in_flow" => Self::ExecutionAlreadyInFlow,
670 "cycle_detected" => Self::CycleDetected,
671 "flow_not_found" => Self::FlowNotFound,
672 "execution_not_in_flow" => Self::ExecutionNotInFlow,
673 "dependency_already_exists" => Self::DependencyAlreadyExists,
674 "self_referencing_edge" => Self::SelfReferencingEdge,
675 "flow_already_terminal" => Self::FlowAlreadyTerminal,
676 "deps_not_satisfied" => Self::DepsNotSatisfied,
677 "not_blocked_by_deps" => Self::NotBlockedByDeps,
678 "not_runnable" => Self::NotRunnable,
679 "terminal" => Self::Terminal,
680 "invalid_blocking_reason" => Self::InvalidBlockingReason,
681 "ok_already_applied" => Self::OkAlreadyApplied,
682 "attempt_not_found" => Self::AttemptNotFound,
683 "attempt_not_in_created_state" => Self::AttemptNotInCreatedState,
684 "attempt_not_started" => Self::AttemptNotStarted,
685 "attempt_already_terminal" => Self::AttemptAlreadyTerminal,
686 "execution_not_found" => Self::ExecutionNotFound,
687 "execution_not_eligible_for_attempt" => Self::ExecutionNotEligibleForAttempt,
688 "replay_not_allowed" => Self::ReplayNotAllowed,
689 "max_retries_exhausted" => Self::MaxRetriesExhausted,
690 "stream_not_found" => Self::StreamNotFound,
691 "stream_already_closed" => Self::StreamAlreadyClosed,
692 "invalid_frame_type" => Self::InvalidFrameType,
693 "invalid_offset" => Self::InvalidOffset,
694 "unauthorized" => Self::Unauthorized,
695 "budget_not_found" => Self::BudgetNotFound,
696 "invalid_budget_scope" => Self::InvalidBudgetScope,
697 "budget_attach_conflict" => Self::BudgetAttachConflict,
698 "budget_override_not_allowed" => Self::BudgetOverrideNotAllowed,
699 "quota_policy_not_found" => Self::QuotaPolicyNotFound,
700 "rate_limit_exceeded" => Self::RateLimitExceeded,
701 "concurrency_limit_exceeded" => Self::ConcurrencyLimitExceeded,
702 "quota_attach_conflict" => Self::QuotaAttachConflict,
703 "invalid_quota_spec" => Self::InvalidQuotaSpec,
704 "invalid_input" => Self::InvalidInput(String::new()),
705 "capability_mismatch" => Self::CapabilityMismatch(String::new()),
706 "invalid_capabilities" => Self::InvalidCapabilities(String::new()),
707 "invalid_policy_json" => Self::InvalidPolicyJson(String::new()),
708 "waitpoint_not_token_bound" => Self::WaitpointNotTokenBound,
709 "invalid_kid" => Self::InvalidKid,
710 "invalid_secret_hex" => Self::InvalidSecretHex,
711 "invalid_grace_ms" => Self::InvalidGraceMs,
712 "rotation_conflict" => Self::RotationConflict(String::new()),
713 "invalid_tag_key" => Self::InvalidTagKey(String::new()),
714 "fence_required" => Self::FenceRequired,
715 "partial_fence_triple" => Self::PartialFenceTriple,
716 _ => return None,
717 })
718 }
719
720 pub fn from_code_with_detail(code: &str, detail: &str) -> Option<Self> {
728 Self::from_code_with_details(code, std::slice::from_ref(&detail))
729 }
730
731 pub fn from_code_with_details(code: &str, details: &[&str]) -> Option<Self> {
737 let base = Self::from_code(code)?;
738 let d = |i: usize| details.get(i).copied().unwrap_or("").to_owned();
739 Some(match base {
740 Self::CapabilityMismatch(_) => Self::CapabilityMismatch(d(0)),
741 Self::InvalidCapabilities(_) => Self::InvalidCapabilities(d(0)),
742 Self::InvalidPolicyJson(_) => Self::InvalidPolicyJson(d(0)),
743 Self::InvalidInput(_) => Self::InvalidInput(d(0)),
744 Self::RotationConflict(_) => Self::RotationConflict(d(0)),
745 Self::InvalidTagKey(_) => Self::InvalidTagKey(d(0)),
746 Self::ExecutionNotActive { .. } => Self::ExecutionNotActive {
747 terminal_outcome: d(0),
748 lease_epoch: d(1),
749 lifecycle_phase: d(2),
750 attempt_id: d(3),
751 },
752 other => other,
753 })
754 }
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760
761 #[test]
762 fn error_classification_terminal() {
763 assert_eq!(ScriptError::StaleLease.class(), ErrorClass::Terminal);
764 assert_eq!(ScriptError::LeaseExpired.class(), ErrorClass::Terminal);
765 assert_eq!(ScriptError::ExecutionNotFound.class(), ErrorClass::Terminal);
766 }
767
768 #[test]
769 fn error_classification_retryable() {
770 assert_eq!(
771 ScriptError::UseClaimResumedExecution.class(),
772 ErrorClass::Retryable
773 );
774 assert_eq!(
775 ScriptError::NoEligibleExecution.class(),
776 ErrorClass::Retryable
777 );
778 assert_eq!(
779 ScriptError::WaitpointNotFound.class(),
780 ErrorClass::Retryable
781 );
782 assert_eq!(
783 ScriptError::RateLimitExceeded.class(),
784 ErrorClass::Retryable
785 );
786 }
787
788 #[test]
789 fn error_classification_cooperative() {
790 assert_eq!(ScriptError::BudgetExceeded.class(), ErrorClass::Cooperative);
791 }
792
793 #[cfg(feature = "valkey-client")]
794 #[test]
795 fn error_classification_valkey_transient_is_retryable() {
796 use ferriskey::ErrorKind;
797 let transient = ScriptError::Valkey(ferriskey::Error::from((
798 ErrorKind::IoError,
799 "connection dropped",
800 )));
801 assert_eq!(transient.class(), ErrorClass::Retryable);
802 }
803
804 #[cfg(feature = "valkey-client")]
805 #[test]
806 fn error_classification_valkey_permanent_is_terminal() {
807 use ferriskey::ErrorKind;
808 let permanent = ScriptError::Valkey(ferriskey::Error::from((
809 ErrorKind::AuthenticationFailed,
810 "bad creds",
811 )));
812 assert_eq!(permanent.class(), ErrorClass::Terminal);
813
814 let fatal_recv = ScriptError::Valkey(ferriskey::Error::from((
817 ErrorKind::FatalReceiveError,
818 "response lost",
819 )));
820 assert_eq!(fatal_recv.class(), ErrorClass::Terminal);
821 }
822
823 #[test]
824 fn error_classification_informational() {
825 assert_eq!(
826 ScriptError::ExecutionNotSuspended.class(),
827 ErrorClass::Informational
828 );
829 assert_eq!(
830 ScriptError::DuplicateSignal.class(),
831 ErrorClass::Informational
832 );
833 assert_eq!(
834 ScriptError::OkAlreadyApplied.class(),
835 ErrorClass::Informational
836 );
837 }
838
839 #[test]
840 fn error_classification_bug() {
841 assert_eq!(ScriptError::ActiveAttemptExists.class(), ErrorClass::Bug);
842 assert_eq!(
843 ScriptError::AttemptNotInCreatedState.class(),
844 ErrorClass::Bug
845 );
846 }
847
848 #[test]
849 fn error_classification_expected() {
850 assert_eq!(ScriptError::StreamNotFound.class(), ErrorClass::Expected);
851 }
852
853 #[test]
854 fn error_classification_budget_soft_exceeded() {
855 assert_eq!(
857 ScriptError::BudgetSoftExceeded.class(),
858 ErrorClass::Informational
859 );
860 }
861
862 #[test]
863 fn error_classification_soft_error() {
864 assert_eq!(ScriptError::InvalidFrameType.class(), ErrorClass::SoftError);
865 }
866
867 #[test]
868 fn from_code_roundtrip() {
869 let codes = [
870 "stale_lease", "lease_expired", "lease_revoked",
871 "execution_not_active", "no_active_lease", "active_attempt_exists",
872 "use_claim_resumed_execution", "not_a_resumed_execution",
873 "execution_not_leaseable", "lease_conflict",
874 "invalid_claim_grant", "claim_grant_expired",
875 "budget_exceeded", "budget_soft_exceeded",
876 "execution_not_suspended", "already_suspended",
877 "waitpoint_closed", "waitpoint_not_found",
878 "target_not_signalable", "waitpoint_pending_use_buffer_script",
879 "invalid_lease_for_suspend", "resume_condition_not_met",
880 "signal_limit_exceeded",
881 "execution_not_terminal", "max_replays_exhausted",
882 "stream_closed", "stale_owner_cannot_append", "retention_limit_exceeded",
883 "execution_not_eligible", "execution_not_in_eligible_set",
884 "grant_already_exists", "execution_not_reclaimable",
885 "invalid_dependency", "stale_graph_revision",
886 "execution_already_in_flow", "cycle_detected",
887 "execution_not_found", "max_retries_exhausted",
888 "flow_not_found", "execution_not_in_flow",
889 "dependency_already_exists", "self_referencing_edge",
890 "flow_already_terminal",
891 "deps_not_satisfied", "not_blocked_by_deps",
892 "not_runnable", "terminal", "invalid_blocking_reason",
893 "waitpoint_not_pending", "pending_waitpoint_expired",
894 "invalid_waitpoint_for_execution", "waitpoint_already_exists",
895 "waitpoint_not_open",
896 ];
897 for code in codes {
898 let err = ScriptError::from_code(code);
899 assert!(err.is_some(), "failed to parse code: {code}");
900 }
901 }
902
903 #[test]
904 fn from_code_unknown_returns_none() {
905 assert!(ScriptError::from_code("nonexistent_error").is_none());
906 }
907
908 #[test]
909 fn fence_required_classifies_terminal() {
910 assert_eq!(ScriptError::FenceRequired.class(), ErrorClass::Terminal);
911 assert_eq!(
912 ScriptError::PartialFenceTriple.class(),
913 ErrorClass::Terminal
914 );
915 }
916
917 #[test]
918 fn fence_required_from_code_roundtrips() {
919 assert!(matches!(
920 ScriptError::from_code("fence_required"),
921 Some(ScriptError::FenceRequired)
922 ));
923 assert!(matches!(
924 ScriptError::from_code("partial_fence_triple"),
925 Some(ScriptError::PartialFenceTriple)
926 ));
927 }
928
929 #[test]
935 fn parse_structured_fields_render_and_match() {
936 let with_exec = ScriptError::Parse {
937 fcall: "ff_claim_execution".into(),
938 execution_id: Some("018f-abc".into()),
939 message: "expected Array".into(),
940 };
941 assert_eq!(
942 with_exec.to_string(),
943 "parse error: ff_claim_execution[exec=018f-abc]: expected Array"
944 );
945 assert!(matches!(
946 &with_exec,
947 ScriptError::Parse { execution_id: Some(e), .. } if e == "018f-abc"
948 ));
949
950 let no_exec = ScriptError::Parse {
951 fcall: "stream_tail_decode".into(),
952 execution_id: None,
953 message: "unexpected array length 3".into(),
954 };
955 assert_eq!(
956 no_exec.to_string(),
957 "parse error: stream_tail_decode: unexpected array length 3"
958 );
959 assert!(matches!(
960 &no_exec,
961 ScriptError::Parse { execution_id: None, fcall, .. } if !fcall.is_empty()
962 ));
963 }
964}