Skip to main content

ff_script/
error.rs

1//! FlowFabric `ScriptError` — Lua error codes + transport errors.
2//!
3//! Lives in `ff-script` (not `ff-core`) because the `Valkey` variant wraps
4//! `ferriskey::Error`, and `ff-core` stays pure of the transport client.
5//! `ff-core` continues to own the `ErrorClass` enum.
6
7use ff_core::error::ErrorClass;
8
9#[cfg(feature = "valkey-client")]
10use crate::retry::is_retryable_kind;
11
12/// All error codes returned by FlowFabric Valkey Functions.
13/// Matches RFC-010 §10.7 exactly.
14///
15/// Does not derive `Serialize`/`Deserialize`/`PartialEq`/`Eq`/`Hash` because the
16/// `Valkey` variant wraps `ferriskey::Error`, which implements none of those.
17/// Call sites compare via `matches!`/`.class()` rather than `==`, so this is
18/// not a regression.
19#[derive(Debug, thiserror::Error)]
20#[non_exhaustive]
21pub enum ScriptError {
22    // ── Lease/Ownership errors ──
23    /// Stop. Lease superseded by reclaim.
24    #[error("stale_lease: lease superseded by reclaim")]
25    StaleLease,
26
27    /// Stop. Lease TTL elapsed.
28    #[error("lease_expired: lease TTL elapsed")]
29    LeaseExpired,
30
31    /// Stop. Operator revoked.
32    #[error("lease_revoked: operator revoked lease")]
33    LeaseRevoked,
34
35    /// Execution is not in `active` state. Carries enriched detail so the
36    /// SDK can reconcile a replay of a terminal operation after a network
37    /// drop: if `terminal_outcome` matches what the caller attempted AND
38    /// `lease_epoch` matches their lease, the prior call committed and
39    /// the retry should be treated as a successful replay. `lifecycle_phase`
40    /// distinguishes `terminal` from `runnable` (retry-scheduled).
41    /// `attempt_id` is the per-attempt replay guard (preserved on
42    /// terminal, cleared on retry).
43    #[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    /// Revoke target has no active lease (already revoked/expired/unowned).
54    #[error("no_active_lease: target has no active lease")]
55    NoActiveLease,
56
57    /// RFC #58.5: a lease-bound FCALL was invoked with an empty
58    /// `(lease_id, lease_epoch, attempt_id)` triple and the caller is not
59    /// an allowlisted operator override. Worker-path callers must always
60    /// pass the fence triple; unfenced callers must supply
61    /// `source == "operator_override"` (terminal ops) or use a different
62    /// FCALL (renew / suspend hard-reject with no override path).
63    /// TERMINAL: the caller is structurally incorrect; retrying without a
64    /// fence will not help.
65    #[error("fence_required: lease fence triple is mandatory for this FCALL")]
66    FenceRequired,
67
68    /// RFC #58.5: the fence triple arrived with some but not all three
69    /// fields populated. Programming error — either all of
70    /// (lease_id, lease_epoch, attempt_id) must be set, or all three must
71    /// be empty. TERMINAL.
72    #[error("partial_fence_triple: lease_id/lease_epoch/attempt_id must be all set or all empty")]
73    PartialFenceTriple,
74
75    /// Bug. Active attempt already exists.
76    #[error("active_attempt_exists: invariant violation")]
77    ActiveAttemptExists,
78
79    // ── Claim dispatch errors ──
80    /// Re-dispatch to claim_resumed_execution.
81    #[error("use_claim_resumed_execution: attempt_interrupted, use resume claim path")]
82    UseClaimResumedExecution,
83
84    /// Re-dispatch to claim_execution.
85    #[error("not_a_resumed_execution: use normal claim path")]
86    NotAResumedExecution,
87
88    /// State changed since grant. Request new grant.
89    #[error("execution_not_leaseable: state changed since grant")]
90    ExecutionNotLeaseable,
91
92    /// Another worker holds lease. Request different execution.
93    #[error("lease_conflict: another worker holds lease")]
94    LeaseConflict,
95
96    /// Grant missing/mismatched. Request new grant.
97    #[error("invalid_claim_grant: grant missing or mismatched")]
98    InvalidClaimGrant,
99
100    /// Grant TTL elapsed. Request new grant.
101    #[error("claim_grant_expired: grant TTL elapsed")]
102    ClaimGrantExpired,
103
104    /// Backoff 100ms-1s, retry.
105    #[error("no_eligible_execution: no execution available")]
106    NoEligibleExecution,
107
108    // ── Budget/Quota enforcement ──
109    /// Immediate stop. Call fail_execution(budget_exceeded).
110    #[error("budget_exceeded: hard budget limit reached")]
111    BudgetExceeded,
112
113    /// Log warning. Continue.
114    #[error("budget_soft_exceeded: soft budget limit reached")]
115    BudgetSoftExceeded,
116
117    // ── Suspension/Signal errors ──
118    /// Already resumed/cancelled. No-op.
119    #[error("execution_not_suspended: already resumed or cancelled")]
120    ExecutionNotSuspended,
121
122    /// Open suspension exists. No-op.
123    #[error("already_suspended: suspension already active")]
124    AlreadySuspended,
125
126    /// Signal too late. Return to caller.
127    #[error("waitpoint_closed: waitpoint already closed")]
128    WaitpointClosed,
129
130    /// Waitpoint may not exist yet. Retry with backoff.
131    #[error("waitpoint_not_found: waitpoint does not exist yet")]
132    WaitpointNotFound,
133
134    /// Execution not suspended, no pending waitpoint.
135    #[error("target_not_signalable: no valid signal target")]
136    TargetNotSignalable,
137
138    /// Route to buffer_signal_for_pending_waitpoint.
139    #[error("waitpoint_pending_use_buffer_script: route to buffer script")]
140    WaitpointPendingUseBufferScript,
141
142    /// Dedup. Return existing signal_id.
143    #[error("duplicate_signal: signal already delivered")]
144    DuplicateSignal,
145
146    /// Payload > 64KB.
147    #[error("payload_too_large: signal payload exceeds 64KB")]
148    PayloadTooLarge,
149
150    /// Max signals reached.
151    #[error("signal_limit_exceeded: max signals per execution reached")]
152    SignalLimitExceeded,
153
154    /// MAC failed on the waitpoint_key field itself (structural
155    /// mismatch against the stored binding).
156    #[error("invalid_waitpoint_key: MAC verification failed")]
157    InvalidWaitpointKey,
158
159    /// MAC failed on the HMAC waitpoint_token bearer credential
160    /// (signal-delivery path). Distinct from [`Self::InvalidWaitpointKey`]
161    /// because the operator-surfaced error code is different — callers
162    /// typically render the Lua code verbatim in 4xx response bodies.
163    #[error("invalid_token: waitpoint_token HMAC verification failed")]
164    InvalidToken,
165
166    /// Invalid lease for suspend.
167    #[error("invalid_lease_for_suspend: lease/attempt binding mismatch")]
168    InvalidLeaseForSuspend,
169
170    /// Conditions not satisfied.
171    #[error("resume_condition_not_met: resume conditions not satisfied")]
172    ResumeConditionNotMet,
173
174    /// Waitpoint not in pending state.
175    #[error("waitpoint_not_pending: waitpoint is not in pending state")]
176    WaitpointNotPending,
177
178    /// Pending waitpoint expired before suspension committed.
179    #[error("pending_waitpoint_expired: pending waitpoint aged out")]
180    PendingWaitpointExpired,
181
182    /// Waitpoint/execution binding mismatch.
183    #[error("invalid_waitpoint_for_execution: waitpoint does not belong to execution")]
184    InvalidWaitpointForExecution,
185
186    /// Waitpoint already exists (pending or active).
187    #[error("waitpoint_already_exists: waitpoint already exists")]
188    WaitpointAlreadyExists,
189
190    /// Waitpoint not in an open state.
191    #[error("waitpoint_not_open: waitpoint is not pending or active")]
192    WaitpointNotOpen,
193
194    // ── Replay errors ──
195    /// Cannot replay non-terminal.
196    #[error("execution_not_terminal: cannot replay non-terminal execution")]
197    ExecutionNotTerminal,
198
199    /// Replay limit reached.
200    #[error("max_replays_exhausted: replay limit reached")]
201    MaxReplaysExhausted,
202
203    // ── Stream errors ──
204    /// Attempt terminal. No appends.
205    #[error("stream_closed: attempt terminal, no appends allowed")]
206    StreamClosed,
207
208    /// Lease mismatch on stream append.
209    #[error("stale_owner_cannot_append: lease mismatch on append")]
210    StaleOwnerCannotAppend,
211
212    /// Frame > 64KB.
213    #[error("retention_limit_exceeded: frame exceeds size limit")]
214    RetentionLimitExceeded,
215
216    // ── Scheduling errors ──
217    /// State changed. Scheduler skips.
218    #[error("execution_not_eligible: state changed")]
219    ExecutionNotEligible,
220
221    /// Another scheduler got it. Skip.
222    #[error("execution_not_in_eligible_set: removed by another scheduler")]
223    ExecutionNotInEligibleSet,
224
225    /// Grant already issued. Skip.
226    #[error("grant_already_exists: grant already active")]
227    GrantAlreadyExists,
228
229    /// Already reclaimed/cancelled. Skip.
230    #[error("execution_not_reclaimable: already reclaimed or cancelled")]
231    ExecutionNotReclaimable,
232
233    // ── Flow/Dependency errors ──
234    /// Edge doesn't exist.
235    #[error("invalid_dependency: dependency edge not found")]
236    InvalidDependency,
237
238    /// Re-read adjacency, retry.
239    #[error("stale_graph_revision: graph has been updated")]
240    StaleGraphRevision,
241
242    /// Already in another flow.
243    #[error("execution_already_in_flow: execution belongs to another flow")]
244    ExecutionAlreadyInFlow,
245
246    /// Edge would create cycle.
247    #[error("cycle_detected: dependency edge would create cycle")]
248    CycleDetected,
249
250    /// Flow does not exist.
251    #[error("flow_not_found: flow does not exist")]
252    FlowNotFound,
253
254    /// Execution is not a member of the specified flow.
255    #[error("execution_not_in_flow: execution not in flow")]
256    ExecutionNotInFlow,
257
258    /// Dependency edge already exists.
259    #[error("dependency_already_exists: edge already exists")]
260    DependencyAlreadyExists,
261
262    /// Self-referencing edge (upstream == downstream).
263    #[error("self_referencing_edge: upstream and downstream are the same")]
264    SelfReferencingEdge,
265
266    /// Flow is already in a terminal state (cancelled/completed/failed).
267    #[error("flow_already_terminal: flow is already terminal")]
268    FlowAlreadyTerminal,
269
270    /// Dependencies not yet satisfied (for promote_blocked_to_eligible).
271    #[error("deps_not_satisfied: dependencies still unresolved")]
272    DepsNotSatisfied,
273
274    /// Not blocked by dependencies (for promote/unblock).
275    #[error("not_blocked_by_deps: execution not blocked by dependencies")]
276    NotBlockedByDeps,
277
278    /// Execution not runnable (for block/unblock/promote).
279    #[error("not_runnable: execution is not in runnable state")]
280    NotRunnable,
281
282    /// Execution is terminal (for block/promote).
283    #[error("terminal: execution is already terminal")]
284    Terminal,
285
286    /// Invalid blocking reason for block_execution_for_admission.
287    #[error("invalid_blocking_reason: unrecognized blocking reason")]
288    InvalidBlockingReason,
289
290    // ── Usage reporting ──
291    /// Usage seq already processed. No-op.
292    #[error("ok_already_applied: usage seq already processed")]
293    OkAlreadyApplied,
294
295    // ── Attempt errors (RFC-002) ──
296    /// Attempt index doesn't exist.
297    #[error("attempt_not_found: attempt index does not exist")]
298    AttemptNotFound,
299
300    /// Attempt not created. Internal sequencing error.
301    #[error("attempt_not_in_created_state: internal sequencing error")]
302    AttemptNotInCreatedState,
303
304    /// Attempt not running.
305    #[error("attempt_not_started: attempt not in started state")]
306    AttemptNotStarted,
307
308    /// Already ended. No-op.
309    #[error("attempt_already_terminal: attempt already ended")]
310    AttemptAlreadyTerminal,
311
312    /// Execution doesn't exist.
313    #[error("execution_not_found: execution does not exist")]
314    ExecutionNotFound,
315
316    /// Wrong state for new attempt.
317    #[error("execution_not_eligible_for_attempt: wrong state for new attempt")]
318    ExecutionNotEligibleForAttempt,
319
320    /// Not terminal or limit reached.
321    #[error("replay_not_allowed: execution not terminal or limit reached")]
322    ReplayNotAllowed,
323
324    /// Retry limit reached.
325    #[error("max_retries_exhausted: retry limit reached")]
326    MaxRetriesExhausted,
327
328    // ── Stream errors (RFC-006) ──
329    /// No frames appended yet. Normal for new attempts.
330    #[error("stream_not_found: no frames appended yet")]
331    StreamNotFound,
332
333    /// Already closed. No-op.
334    #[error("stream_already_closed: stream already closed")]
335    StreamAlreadyClosed,
336
337    /// Unrecognized frame type.
338    #[error("invalid_frame_type: unrecognized frame type")]
339    InvalidFrameType,
340
341    /// Invalid Stream ID.
342    #[error("invalid_offset: invalid stream ID offset")]
343    InvalidOffset,
344
345    /// Auth failed.
346    #[error("unauthorized: authentication/authorization failed")]
347    Unauthorized,
348
349    // ── Budget/Quota errors (RFC-008) ──
350    /// Budget doesn't exist.
351    #[error("budget_not_found: budget does not exist")]
352    BudgetNotFound,
353
354    /// Malformed scope.
355    #[error("invalid_budget_scope: malformed budget scope")]
356    InvalidBudgetScope,
357
358    /// Budget already attached or conflicts.
359    #[error("budget_attach_conflict: budget attachment conflict")]
360    BudgetAttachConflict,
361
362    /// No operator privileges.
363    #[error("budget_override_not_allowed: insufficient privileges")]
364    BudgetOverrideNotAllowed,
365
366    /// Quota doesn't exist.
367    #[error("quota_policy_not_found: quota policy does not exist")]
368    QuotaPolicyNotFound,
369
370    /// Window full. Backoff retry_after_ms.
371    #[error("rate_limit_exceeded: rate limit window full")]
372    RateLimitExceeded,
373
374    /// Concurrency cap hit.
375    #[error("concurrency_limit_exceeded: concurrency cap reached")]
376    ConcurrencyLimitExceeded,
377
378    /// Quota already attached.
379    #[error("quota_attach_conflict: quota policy already attached")]
380    QuotaAttachConflict,
381
382    /// Malformed quota definition.
383    #[error("invalid_quota_spec: malformed quota policy definition")]
384    InvalidQuotaSpec,
385
386    /// Caller supplied a non-numeric value where a number is required.
387    #[error("invalid_input: {0}")]
388    InvalidInput(String),
389
390    /// Worker caps do not satisfy execution's required_capabilities.
391    /// Payload is the sorted-CSV of missing tokens. RETRYABLE: execution
392    /// stays in the eligible ZSET for a worker with matching caps.
393    #[error("capability_mismatch: missing {0}")]
394    CapabilityMismatch(String),
395
396    /// Caller supplied a malformed or oversized capability list (defense
397    /// against 1MB-repeated-token payloads). TERMINAL from this call's
398    /// perspective: the caller must fix its config before retrying.
399    #[error("invalid_capabilities: {0}")]
400    InvalidCapabilities(String),
401
402    /// `ff_create_execution` received a `policy_json` that is not valid JSON
403    /// or whose `routing_requirements` is structurally wrong (not an object,
404    /// required_capabilities not an array). TERMINAL: the submitter must
405    /// send a well-formed policy. Kept distinct from `invalid_capabilities`
406    /// so tooling can distinguish "payload never parsed" from "payload
407    /// parsed but contents rejected".
408    #[error("invalid_policy_json: {0}")]
409    InvalidPolicyJson(String),
410
411    /// Pending waitpoint record is missing its HMAC token field. Returned by
412    /// `ff_suspend_execution` when activating a pending waitpoint whose
413    /// `waitpoint_token` field is absent or empty (pre-HMAC-upgrade record
414    /// or a corrupted write). Surfacing this at activation time instead of
415    /// letting every subsequent signal delivery silently reject with
416    /// `missing_token` makes the degraded state visible at the right step.
417    /// TERMINAL: the pending waitpoint is unrecoverable without a fresh one.
418    #[error("waitpoint_not_token_bound")]
419    WaitpointNotTokenBound,
420
421    /// Rotation FCALL: `new_kid` empty or contains `:`.
422    #[error("invalid_kid: kid must be non-empty and contain no ':'")]
423    InvalidKid,
424
425    /// Rotation FCALL: `new_secret_hex` empty, odd length, or non-hex.
426    #[error("invalid_secret_hex: secret must be a non-empty even-length hex string")]
427    InvalidSecretHex,
428
429    /// Rotation FCALL: `grace_ms` not a non-negative integer.
430    #[error("invalid_grace_ms: grace_ms must be a non-negative integer")]
431    InvalidGraceMs,
432
433    /// Rotation FCALL: same kid already installed with a different secret.
434    /// Carries the kid so operators see which one conflicted. Refuse — the
435    /// operator must pick a fresh kid or restore the stored secret.
436    #[error("rotation_conflict: kid {0} already installed with a different secret")]
437    RotationConflict(String),
438
439    /// `ff_set_execution_tags` / `ff_set_flow_tags` received a tag key
440    /// that does not match the reserved namespace `^[a-z][a-z0-9_]*\.`.
441    /// Callers must prefix tag keys with `<caller>.` (e.g.
442    /// `cairn.task_id`). TERMINAL: caller must fix input before retry.
443    /// Payload is the offending key for precise diagnostics.
444    #[error("invalid_tag_key: {0}")]
445    InvalidTagKey(String),
446
447    // ── Transport-level errors (not from Lua) ──
448    /// Valkey connection or protocol error. Preserves `ferriskey::ErrorKind` so
449    /// callers can distinguish transient/permanent/NOSCRIPT/MOVED/etc.
450    ///
451    /// Issue #171: gated behind `valkey-client` (default-on). Consumers
452    /// building `ff-sdk --no-default-features` get a ferriskey-free
453    /// `ScriptError` enum. All non-transport variants remain available.
454    #[cfg(feature = "valkey-client")]
455    #[error("valkey: {0}")]
456    Valkey(#[from] ferriskey::Error),
457
458    /// Failed to parse FCALL return value. `fcall` names the FCALL OR the
459    /// nearest semantic parser (e.g. `"parse_report_usage_result"`,
460    /// `"stream_tail_decode"`, `"decode_flow_snapshot"`). Never empty.
461    /// `execution_id` is populated at sites where the exec_id is in scope
462    /// (the 13 task.rs sites) and `None` elsewhere. `message` carries
463    /// expected-vs-got detail.
464    #[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
472/// Renders `ScriptError::Parse` as
473/// `parse error: <fcall>[exec=...]: <message>`. The `[exec=...]` slot is
474/// omitted when `execution_id` is `None`.
475fn 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    /// Returns the underlying ferriskey ErrorKind if this is a transport error.
484    ///
485    /// Issue #171: gated with the `Valkey` variant behind `valkey-client`.
486    #[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    /// Classify this error for SDK action dispatch.
495    pub fn class(&self) -> ErrorClass {
496        match self {
497            // Terminal
498            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            // Transport errors classify by their ferriskey ErrorKind —
554            // IoError / FatalSend / TryAgain / BusyLoading / ClusterDown are
555            // genuinely retryable even though all other Valkey errors are
556            // terminal from the caller's perspective.
557            #[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            // Retryable
567            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            // Cooperative
583            Self::BudgetExceeded => ErrorClass::Cooperative,
584
585            // Informational
586            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            // Bug
607            Self::ActiveAttemptExists | Self::AttemptNotInCreatedState => ErrorClass::Bug,
608
609            // Expected
610            Self::StreamNotFound => ErrorClass::Expected,
611
612            // Soft error
613            Self::InvalidFrameType => ErrorClass::SoftError,
614        }
615    }
616
617    /// Parse an error code string (from Lua return) into a ScriptError.
618    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    /// Like `from_code`, but preserves the Lua-side detail payload for
721    /// variants that carry a String. Lua returns `{0, code, detail}` for
722    /// capability_mismatch (missing CSV), invalid_capabilities (bounds
723    /// reason), invalid_input (field name). The plain `from_code` discards
724    /// the detail; callers that log or surface the detail should use this
725    /// variant. Returns `None` only when the code is unknown — the detail
726    /// is always folded in when applicable.
727    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    /// Like `from_code_with_detail`, but accepts multi-field details for
732    /// variants whose Lua return carries more than one detail slot (e.g.
733    /// `ExecutionNotActive` ships `terminal_outcome, lease_epoch,
734    /// lifecycle_phase, attempt_id` at indexes 2..=5). Indexes below the
735    /// variant's expected arity default to `""`.
736    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        // FatalReceiveError: request may have been applied, conservatively
815        // terminal.
816        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        // RFC-010 §10.7: budget_soft_exceeded is INFORMATIONAL
856        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    /// Regression (#98): `ScriptError::Parse` carries `fcall`, optional
930    /// `execution_id`, and `message` separately. Display renders
931    /// `parse error: <fcall>[exec=<id>]: <message>` when `execution_id`
932    /// is `Some`, and omits the `[exec=...]` slot when `None`. `fcall`
933    /// must never be empty.
934    #[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}