Skip to main content

ff_script/
engine_error_ext.rs

1//! ScriptError-aware extension helpers for `ff_core::EngineError`.
2//!
3//! **RFC-012 Stage 1a:** the enum types live in ff-core so the
4//! `EngineBackend` trait can name them without ff-core depending on
5//! ff-script. The ScriptError-downcast logic cannot live in ff-core
6//! (naming `ScriptError` would require ff-core → ff-script, wrong
7//! direction). This module owns:
8//!
9//! * `impl From<ScriptError> for EngineError` — the full mapping.
10//! * [`transport_script`] / [`transport_script_ref`] — construct a
11//!   Valkey-tagged Transport variant from a `ScriptError` and downcast
12//!   the boxed source back to `ScriptError` respectively.
13//! * [`valkey_kind`] — inspect the inner `ferriskey::ErrorKind` when
14//!   the Transport variant carries a `ScriptError::Valkey`.
15//! * [`class`] — upgrade ff-core's default `Terminal` classification
16//!   of Transport into the correct Phase-1 classification by
17//!   delegating to `ScriptError::class`.
18
19use crate::error::ScriptError;
20use ff_core::engine_error::{
21    BugKind, ConflictKind, ContentionKind, EngineError, StateKind, ValidationKind,
22};
23use ff_core::error::ErrorClass;
24
25/// Construct a Valkey-backed `Transport` from a [`ScriptError`].
26/// Preferred over struct-literal construction so the `backend` tag
27/// stays consistent across Valkey call sites.
28pub fn transport_script(err: ScriptError) -> EngineError {
29    EngineError::Transport {
30        backend: "valkey",
31        source: Box::new(err),
32    }
33}
34
35/// If `err` is a `Transport` carrying a [`ScriptError`], return a
36/// reference to the inner script error. Returns `None` for other
37/// variants and for Transport whose inner source is not a
38/// `ScriptError` (e.g. a Postgres-backed transport error).
39pub fn transport_script_ref(err: &EngineError) -> Option<&ScriptError> {
40    match err {
41        EngineError::Transport { source, .. } => source.downcast_ref::<ScriptError>(),
42        _ => None,
43    }
44}
45
46/// Returns the underlying ferriskey [`ErrorKind`] if `err` maps back
47/// to a transport-level fault whose inner source is a [`ScriptError`].
48/// Postgres-backed or other non-Valkey transport errors return `None`.
49///
50/// [`ErrorKind`]: ferriskey::ErrorKind
51pub fn valkey_kind(err: &EngineError) -> Option<ferriskey::ErrorKind> {
52    match err {
53        EngineError::Transport { source, .. } => source
54            .downcast_ref::<ScriptError>()
55            .and_then(|s| s.valkey_kind()),
56        _ => None,
57    }
58}
59
60/// Classification of `err` using the ScriptError-aware table.
61///
62/// Delegates to `EngineError::class()` for non-Transport variants;
63/// for `Transport`, downcasts the inner source to `ScriptError` and
64/// delegates to `ScriptError::class()`. Non-Valkey transport errors
65/// (no `ScriptError` inside) default to `Terminal` — retrying an
66/// unknown-shape error is unsafe.
67pub fn class(err: &EngineError) -> ErrorClass {
68    match err {
69        EngineError::Transport { source, .. } => source
70            .downcast_ref::<ScriptError>()
71            .map(|s| s.class())
72            .unwrap_or(ErrorClass::Terminal),
73        other => other.class(),
74    }
75}
76
77impl From<ScriptError> for EngineError {
78    fn from(err: ScriptError) -> Self {
79        use ScriptError as S;
80        match err {
81            // ── NotFound ──
82            S::ExecutionNotFound => Self::NotFound {
83                entity: "execution",
84            },
85            S::FlowNotFound => Self::NotFound { entity: "flow" },
86            S::AttemptNotFound => Self::NotFound { entity: "attempt" },
87            S::BudgetNotFound => Self::NotFound { entity: "budget" },
88            S::QuotaPolicyNotFound => Self::NotFound {
89                entity: "quota_policy",
90            },
91            S::StreamNotFound => Self::NotFound { entity: "stream" },
92
93            // ── Validation (carries detail) ──
94            S::InvalidInput(d) => Self::Validation {
95                kind: ValidationKind::InvalidInput,
96                detail: d,
97            },
98            S::CapabilityMismatch(d) => Self::Validation {
99                kind: ValidationKind::CapabilityMismatch,
100                detail: d,
101            },
102            S::InvalidCapabilities(d) => Self::Validation {
103                kind: ValidationKind::InvalidCapabilities,
104                detail: d,
105            },
106            S::InvalidPolicyJson(d) => Self::Validation {
107                kind: ValidationKind::InvalidPolicyJson,
108                detail: d,
109            },
110            S::InvalidTagKey(d) => Self::Validation {
111                kind: ValidationKind::InvalidTagKey,
112                detail: d,
113            },
114            // ── Validation (no detail payload) ──
115            S::PayloadTooLarge => Self::Validation {
116                kind: ValidationKind::PayloadTooLarge,
117                detail: String::new(),
118            },
119            S::SignalLimitExceeded => Self::Validation {
120                kind: ValidationKind::SignalLimitExceeded,
121                detail: String::new(),
122            },
123            S::InvalidWaitpointKey => Self::Validation {
124                kind: ValidationKind::InvalidWaitpointKey,
125                detail: String::new(),
126            },
127            S::WaitpointNotTokenBound => Self::Validation {
128                kind: ValidationKind::WaitpointNotTokenBound,
129                detail: String::new(),
130            },
131            S::RetentionLimitExceeded => Self::Validation {
132                kind: ValidationKind::RetentionLimitExceeded,
133                detail: String::new(),
134            },
135            S::InvalidLeaseForSuspend => Self::Validation {
136                kind: ValidationKind::InvalidLeaseForSuspend,
137                detail: String::new(),
138            },
139            S::InvalidDependency => Self::Validation {
140                kind: ValidationKind::InvalidDependency,
141                detail: String::new(),
142            },
143            S::InvalidWaitpointForExecution => Self::Validation {
144                kind: ValidationKind::InvalidWaitpointForExecution,
145                detail: String::new(),
146            },
147            S::InvalidBlockingReason => Self::Validation {
148                kind: ValidationKind::InvalidBlockingReason,
149                detail: String::new(),
150            },
151            S::InvalidOffset => Self::Validation {
152                kind: ValidationKind::InvalidOffset,
153                detail: String::new(),
154            },
155            S::Unauthorized => Self::Validation {
156                kind: ValidationKind::Unauthorized,
157                detail: String::new(),
158            },
159            S::InvalidBudgetScope => Self::Validation {
160                kind: ValidationKind::InvalidBudgetScope,
161                detail: String::new(),
162            },
163            S::BudgetOverrideNotAllowed => Self::Validation {
164                kind: ValidationKind::BudgetOverrideNotAllowed,
165                detail: String::new(),
166            },
167            S::InvalidQuotaSpec => Self::Validation {
168                kind: ValidationKind::InvalidQuotaSpec,
169                detail: String::new(),
170            },
171            S::InvalidKid => Self::Validation {
172                kind: ValidationKind::InvalidKid,
173                detail: String::new(),
174            },
175            S::InvalidSecretHex => Self::Validation {
176                kind: ValidationKind::InvalidSecretHex,
177                detail: String::new(),
178            },
179            S::InvalidGraceMs => Self::Validation {
180                kind: ValidationKind::InvalidGraceMs,
181                detail: String::new(),
182            },
183            S::InvalidFrameType => Self::Validation {
184                kind: ValidationKind::InvalidFrameType,
185                detail: String::new(),
186            },
187
188            // ── Contention ──
189            S::UseClaimResumedExecution => {
190                Self::Contention(ContentionKind::UseClaimResumedExecution)
191            }
192            S::NotAResumedExecution => Self::Contention(ContentionKind::NotAResumedExecution),
193            S::ExecutionNotLeaseable => Self::Contention(ContentionKind::ExecutionNotLeaseable),
194            S::LeaseConflict => Self::Contention(ContentionKind::LeaseConflict),
195            S::InvalidClaimGrant => Self::Contention(ContentionKind::InvalidClaimGrant),
196            S::ClaimGrantExpired => Self::Contention(ContentionKind::ClaimGrantExpired),
197            S::NoEligibleExecution => Self::Contention(ContentionKind::NoEligibleExecution),
198            S::WaitpointNotFound => Self::Contention(ContentionKind::WaitpointNotFound),
199            S::WaitpointPendingUseBufferScript => {
200                Self::Contention(ContentionKind::WaitpointPendingUseBufferScript)
201            }
202            S::StaleGraphRevision => Self::Contention(ContentionKind::StaleGraphRevision),
203            S::ExecutionNotActive {
204                terminal_outcome,
205                lease_epoch,
206                lifecycle_phase,
207                attempt_id,
208            } => Self::Contention(ContentionKind::ExecutionNotActive {
209                terminal_outcome,
210                lease_epoch,
211                lifecycle_phase,
212                attempt_id,
213            }),
214            S::ExecutionNotEligible => Self::Contention(ContentionKind::ExecutionNotEligible),
215            S::ExecutionNotInEligibleSet => {
216                Self::Contention(ContentionKind::ExecutionNotInEligibleSet)
217            }
218            S::ExecutionNotReclaimable => {
219                Self::Contention(ContentionKind::ExecutionNotReclaimable)
220            }
221            S::NoActiveLease => Self::Contention(ContentionKind::NoActiveLease),
222            S::RateLimitExceeded => Self::Contention(ContentionKind::RateLimitExceeded),
223            S::ConcurrencyLimitExceeded => {
224                Self::Contention(ContentionKind::ConcurrencyLimitExceeded)
225            }
226
227            // ── Conflict ──
228            // DependencyAlreadyExists needs a follow-up read to
229            // populate `existing`. Plain `From` cannot do the
230            // async read, so falls through to Transport with the
231            // raw ScriptError preserved — callers enrich via
232            // `ff_sdk::engine_error::enrich_dependency_conflict` at
233            // the stage_dependency site.
234            S::DependencyAlreadyExists => transport_script(S::DependencyAlreadyExists),
235            S::CycleDetected => Self::Conflict(ConflictKind::CycleDetected),
236            S::SelfReferencingEdge => Self::Conflict(ConflictKind::SelfReferencingEdge),
237            S::ExecutionAlreadyInFlow => Self::Conflict(ConflictKind::ExecutionAlreadyInFlow),
238            S::WaitpointAlreadyExists => Self::Conflict(ConflictKind::WaitpointAlreadyExists),
239            S::BudgetAttachConflict => Self::Conflict(ConflictKind::BudgetAttachConflict),
240            S::QuotaAttachConflict => Self::Conflict(ConflictKind::QuotaAttachConflict),
241            S::RotationConflict(kid) => Self::Conflict(ConflictKind::RotationConflict(kid)),
242            S::ActiveAttemptExists => Self::Conflict(ConflictKind::ActiveAttemptExists),
243
244            // ── State ──
245            S::StaleLease => Self::State(StateKind::StaleLease),
246            S::LeaseExpired => Self::State(StateKind::LeaseExpired),
247            S::LeaseRevoked => Self::State(StateKind::LeaseRevoked),
248            S::ExecutionNotSuspended => Self::State(StateKind::ExecutionNotSuspended),
249            S::AlreadySuspended => Self::State(StateKind::AlreadySuspended),
250            S::WaitpointClosed => Self::State(StateKind::WaitpointClosed),
251            S::TargetNotSignalable => Self::State(StateKind::TargetNotSignalable),
252            S::DuplicateSignal => Self::State(StateKind::DuplicateSignal),
253            S::ResumeConditionNotMet => Self::State(StateKind::ResumeConditionNotMet),
254            S::WaitpointNotPending => Self::State(StateKind::WaitpointNotPending),
255            S::PendingWaitpointExpired => Self::State(StateKind::PendingWaitpointExpired),
256            S::WaitpointNotOpen => Self::State(StateKind::WaitpointNotOpen),
257            S::ExecutionNotTerminal => Self::State(StateKind::ExecutionNotTerminal),
258            S::MaxReplaysExhausted => Self::State(StateKind::MaxReplaysExhausted),
259            S::StreamClosed => Self::State(StateKind::StreamClosed),
260            S::StaleOwnerCannotAppend => Self::State(StateKind::StaleOwnerCannotAppend),
261            S::GrantAlreadyExists => Self::State(StateKind::GrantAlreadyExists),
262            S::ExecutionNotInFlow => Self::State(StateKind::ExecutionNotInFlow),
263            S::FlowAlreadyTerminal => Self::State(StateKind::FlowAlreadyTerminal),
264            S::DepsNotSatisfied => Self::State(StateKind::DepsNotSatisfied),
265            S::NotBlockedByDeps => Self::State(StateKind::NotBlockedByDeps),
266            S::NotRunnable => Self::State(StateKind::NotRunnable),
267            S::Terminal => Self::State(StateKind::Terminal),
268            S::BudgetExceeded => Self::State(StateKind::BudgetExceeded),
269            S::BudgetSoftExceeded => Self::State(StateKind::BudgetSoftExceeded),
270            S::OkAlreadyApplied => Self::State(StateKind::OkAlreadyApplied),
271            S::AttemptNotStarted => Self::State(StateKind::AttemptNotStarted),
272            S::AttemptAlreadyTerminal => Self::State(StateKind::AttemptAlreadyTerminal),
273            S::ExecutionNotEligibleForAttempt => {
274                Self::State(StateKind::ExecutionNotEligibleForAttempt)
275            }
276            S::ReplayNotAllowed => Self::State(StateKind::ReplayNotAllowed),
277            S::MaxRetriesExhausted => Self::State(StateKind::MaxRetriesExhausted),
278            S::StreamAlreadyClosed => Self::State(StateKind::StreamAlreadyClosed),
279
280            // ── Bug ──
281            S::AttemptNotInCreatedState => Self::Bug(BugKind::AttemptNotInCreatedState),
282
283            // ── Transport (preserves source for Parse/Valkey) ──
284            e @ (S::Parse { .. } | S::Valkey(_)) => transport_script(e),
285
286            // `ScriptError` is `#[non_exhaustive]`. A future variant
287            // landed in ff-script before the mapping here was updated
288            // falls through to `Transport` with the raw ScriptError
289            // preserved — strict-parse posture: caller still sees the
290            // underlying error without a silent Display-string
291            // downgrade. Adding the explicit variant later is a
292            // non-breaking mapping refinement.
293            other => transport_script(other),
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn not_found_mappings() {
304        assert!(matches!(
305            EngineError::from(ScriptError::ExecutionNotFound),
306            EngineError::NotFound { entity: "execution" }
307        ));
308        assert!(matches!(
309            EngineError::from(ScriptError::FlowNotFound),
310            EngineError::NotFound { entity: "flow" }
311        ));
312    }
313
314    #[test]
315    fn validation_detail_preserved() {
316        match EngineError::from(ScriptError::CapabilityMismatch("gpu,cuda".into())) {
317            EngineError::Validation {
318                kind: ValidationKind::CapabilityMismatch,
319                detail,
320            } => assert_eq!(detail, "gpu,cuda"),
321            other => panic!("{other:?}"),
322        }
323    }
324
325    #[test]
326    fn contention_bucket() {
327        assert!(matches!(
328            EngineError::from(ScriptError::LeaseConflict),
329            EngineError::Contention(ContentionKind::LeaseConflict)
330        ));
331        assert!(matches!(
332            EngineError::from(ScriptError::UseClaimResumedExecution),
333            EngineError::Contention(ContentionKind::UseClaimResumedExecution)
334        ));
335    }
336
337    #[test]
338    fn execution_not_active_detail_flows_through() {
339        let src = ScriptError::ExecutionNotActive {
340            terminal_outcome: "success".into(),
341            lease_epoch: "3".into(),
342            lifecycle_phase: "terminal".into(),
343            attempt_id: "att-1".into(),
344        };
345        match EngineError::from(src) {
346            EngineError::Contention(ContentionKind::ExecutionNotActive {
347                terminal_outcome,
348                lease_epoch,
349                lifecycle_phase,
350                attempt_id,
351            }) => {
352                assert_eq!(terminal_outcome, "success");
353                assert_eq!(lease_epoch, "3");
354                assert_eq!(lifecycle_phase, "terminal");
355                assert_eq!(attempt_id, "att-1");
356            }
357            other => panic!("{other:?}"),
358        }
359    }
360
361    #[test]
362    fn dependency_already_exists_falls_through_to_transport_without_enrich() {
363        let err = EngineError::from(ScriptError::DependencyAlreadyExists);
364        match &err {
365            EngineError::Transport { backend, source } => {
366                assert_eq!(*backend, "valkey");
367                assert!(matches!(
368                    source.downcast_ref::<ScriptError>(),
369                    Some(ScriptError::DependencyAlreadyExists)
370                ));
371            }
372            other => panic!("{other:?}"),
373        }
374    }
375
376    #[test]
377    fn conflict_variants() {
378        assert!(matches!(
379            EngineError::from(ScriptError::CycleDetected),
380            EngineError::Conflict(ConflictKind::CycleDetected)
381        ));
382        assert!(matches!(
383            EngineError::from(ScriptError::ExecutionAlreadyInFlow),
384            EngineError::Conflict(ConflictKind::ExecutionAlreadyInFlow)
385        ));
386        match EngineError::from(ScriptError::RotationConflict("kid-1".into())) {
387            EngineError::Conflict(ConflictKind::RotationConflict(k)) => assert_eq!(k, "kid-1"),
388            other => panic!("{other:?}"),
389        }
390    }
391
392    #[test]
393    fn state_variants() {
394        assert!(matches!(
395            EngineError::from(ScriptError::StaleLease),
396            EngineError::State(StateKind::StaleLease)
397        ));
398        assert!(matches!(
399            EngineError::from(ScriptError::BudgetExceeded),
400            EngineError::State(StateKind::BudgetExceeded)
401        ));
402    }
403
404    #[test]
405    fn bug_variants() {
406        assert!(matches!(
407            EngineError::from(ScriptError::AttemptNotInCreatedState),
408            EngineError::Bug(BugKind::AttemptNotInCreatedState)
409        ));
410    }
411
412    #[test]
413    fn transport_preserves_parse() {
414        let err = EngineError::from(ScriptError::Parse {
415            fcall: "test_fn".into(),
416            execution_id: None,
417            message: "bad envelope".into(),
418        });
419        match &err {
420            EngineError::Transport { backend, source } => {
421                assert_eq!(*backend, "valkey");
422                assert!(matches!(
423                    source.downcast_ref::<ScriptError>(),
424                    Some(ScriptError::Parse { .. })
425                ));
426            }
427            other => panic!("{other:?}"),
428        }
429    }
430
431    #[test]
432    fn transport_script_helper_round_trips() {
433        let err = transport_script(ScriptError::AttemptNotFound);
434        assert!(matches!(
435            transport_script_ref(&err),
436            Some(ScriptError::AttemptNotFound)
437        ));
438        assert_eq!(class(&err), ScriptError::AttemptNotFound.class());
439    }
440
441    #[test]
442    fn transport_preserves_valkey_kind() {
443        let src = ScriptError::Valkey(ferriskey::Error::from((
444            ferriskey::ErrorKind::IoError,
445            "boom",
446        )));
447        let err = EngineError::from(src);
448        assert_eq!(valkey_kind(&err), Some(ferriskey::ErrorKind::IoError));
449    }
450
451    #[test]
452    fn class_transport_delegates() {
453        let err = EngineError::from(ScriptError::Valkey(ferriskey::Error::from((
454            ferriskey::ErrorKind::IoError,
455            "x",
456        ))));
457        assert_eq!(class(&err), ErrorClass::Retryable);
458    }
459
460    #[test]
461    fn class_transport_with_non_script_source_terminal() {
462        let raw = std::io::Error::other("simulated postgres net error");
463        let err = EngineError::Transport {
464            backend: "postgres",
465            source: Box::new(raw),
466        };
467        assert_eq!(class(&err), ErrorClass::Terminal);
468        assert!(valkey_kind(&err).is_none());
469        assert!(transport_script_ref(&err).is_none());
470    }
471
472    #[test]
473    fn unavailable_has_no_script_source() {
474        let err = EngineError::Unavailable { op: "claim" };
475        assert_eq!(class(&err), ErrorClass::Terminal);
476        assert!(valkey_kind(&err).is_none());
477        assert!(transport_script_ref(&err).is_none());
478    }
479}