Skip to main content

laser_sdk/
error.rs

1use crate::fork::ForkError;
2use crate::kv::KvError;
3#[cfg(feature = "provenance")]
4use crate::provenance::ProvenanceError;
5use crate::query::QueryError;
6use crate::types::IdError;
7use iggy::prelude::{IggyError, IggyMessage, SendMessagesConfirmationResponse};
8use laser_wire::error::{DecodeError, InvalidError};
9use laser_wire::filter::FilterErrorReason;
10use laser_wire::result::{CommandError, ResultCode};
11
12/// The one error type every fallible SDK call returns.
13///
14/// LaserData-Cloud-served surfaces fail with their typed wire error nested intact
15/// ([`Query`](Self::Query), [`Kv`](Self::Kv), [`Fork`](Self::Fork)), so callers
16/// can match on the structured cause (`QueryError::TooLarge { cap, .. }`,
17/// `KvError::InvalidKey`, ...) instead of parsing strings. Client-side failures
18/// keep their own variants: [`Codec`](Self::Codec) (encode/decode),
19/// [`Invalid`](Self::Invalid) (validation), [`Protocol`](Self::Protocol)
20/// (unexpected reply shape). The classifier methods ([`is_retryable`](Self::is_retryable),
21/// [`is_unsupported`](Self::is_unsupported), [`is_not_found`](Self::is_not_found))
22/// answer the common questions without matching variants.
23#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum LaserError {
26    /// A fluent or direct publish that gave up. Carries the cause, the
27    /// target, the ranges that committed, and the records left unconfirmed.
28    #[error(transparent)]
29    PublishFailed(Box<PublishFailure>),
30    /// A transient handler-side failure a handler may return to request a retry
31    /// (the reliable consumer retries it under the `RetryPolicy`).
32    /// Deterministic wiring, startup, and configuration failures use
33    /// [`HandlerConfig`](Self::HandlerConfig) instead, which never retries.
34    #[error("handler error: {0}")]
35    Handler(String),
36    /// A deterministic handler wiring, startup, or configuration failure: a task
37    /// that stopped before becoming ready, a join failure, an invalid inbox topic
38    /// or task id, a missing correlation or agent id. Retrying cannot change the
39    /// outcome, so it is never retryable (the reliable consumer dead-letters it at
40    /// once rather than burning the retry budget).
41    #[error("handler configuration error: {0}")]
42    HandlerConfig(String),
43    #[error("rejected: {0}")]
44    Rejected(String),
45    #[error("timed out waiting for {0}")]
46    Timeout(&'static str),
47    /// A managed mutation's outcome is unknown: the attempt timed out or the
48    /// transport failed after dispatch, so the server may or may not have
49    /// applied it. This is deliberately not generically retryable. The caller
50    /// must inspect [`is_ambiguous_mutation`](Self::is_ambiguous_mutation) and
51    /// follow the operation-specific recovery contract.
52    #[error("managed mutation outcome unknown: {0}")]
53    AmbiguousMutation(String),
54    #[error("the agent has no respond_on topic configured")]
55    NoRespondTopic,
56    #[error("state store: {0}")]
57    StateStore(String),
58    #[error("invalid configuration: {0}")]
59    Config(&'static str),
60    #[error(
61        "no default stream set: address one explicitly (laser.stream(name).topic(name)) or pin a default with Laser::connect_with_stream / with_default_stream. Under the substrate's RBAC the stream must also be one this principal is permitted to use"
62    )]
63    NoStream,
64    /// LaserData Cloud answered a query/browse/control op with a typed failure.
65    #[error("query: {0}")]
66    Query(#[from] QueryError),
67    /// LaserData Cloud answered a key-value op with a typed failure.
68    #[error("kv: {0}")]
69    Kv(#[from] KvError),
70    /// LaserData Cloud answered a fork op with a typed failure.
71    #[error("fork: {0}")]
72    Fork(#[from] ForkError),
73    /// LaserData Cloud answered a session read with a typed failure.
74    #[error("session: {0}")]
75    Session(#[from] laser_wire::session::SessionError),
76    /// LaserData Cloud answered a graph op with a typed failure.
77    #[error("graph: {0}")]
78    Graph(#[from] laser_wire::graph::GraphError),
79    /// The streaming server answered an authorization op with a typed failure.
80    #[error("authz: {0}")]
81    Authz(#[from] laser_wire::authz::AuthzError),
82    /// The streaming server or the filter catalog answered a consumer-filter
83    /// operation with a typed failure.
84    #[error("filter: {0}")]
85    Filter(#[from] laser_wire::filter::FilterError),
86    /// A filtered read stopped in front of a record the filter could not
87    /// evaluate under the `stop` fault policy. The record and everything after
88    /// it stay unacknowledged.
89    #[error("filter fault on partition {partition_id} at offset {offset}: {reason}")]
90    FilterFault {
91        partition_id: u32,
92        offset: u64,
93        reason: laser_wire::filter::FaultReason,
94    },
95    /// A matching record alone exceeds the filtered reply cap, so no page can
96    /// carry it. The record and everything after it stay unacknowledged.
97    #[error(
98        "the record at offset {offset} on partition {partition_id} exceeds the filtered reply cap"
99    )]
100    FilterOversizedRecord { partition_id: u32, offset: u64 },
101    /// The consumer group was created, or already existed, but its filter
102    /// could not be configured. The group stays as it is, unbound unless it
103    /// ran a policy before, and no reader joined it. `source` is the typed
104    /// catalog refusal or transport failure.
105    #[error(
106        "consumer group {group_id} ({name}) exists but its filter was not configured: {source}"
107    )]
108    ConsumerGroupSetup {
109        group_id: u32,
110        name: String,
111        identity: laser_wire::filter::FilterGroupIdentity,
112        source: Box<LaserError>,
113    },
114    /// Iggy answered a destination or checkpoint operation with a typed failure.
115    #[error("checkpoint: {0}")]
116    Checkpoint(Box<laser_wire::checkpoint::CheckpointError>),
117    /// A client-side encode or decode failed (payload codec, wire envelope,
118    /// reply bytes). Deterministic for a given input, so never retryable.
119    #[error("codec: {0}")]
120    Codec(String),
121    /// Client-side validation rejected the input before any round-trip
122    /// (size caps, reserved keys, malformed identifiers).
123    #[error("invalid: {0}")]
124    Invalid(String),
125    /// The reply decoded but carried an outcome the request cannot accept
126    /// (e.g. a scan outcome for a get). Indicates server/client skew.
127    #[error("protocol: {0}")]
128    Protocol(String),
129    /// A managed endpoint returned its original result code and message.
130    #[error("managed command: {}", .0.message)]
131    Command(CommandError),
132    /// The connected infrastructure does not provide the requested feature
133    /// (Apache Iggy without LaserData Cloud, or a deployment whose backend
134    /// does not advertise the sub-capability). Permanent by definition: gate
135    /// the code path on [`Laser::capabilities`](crate::laser::Laser) instead
136    /// of retrying. `surface` names the accessor the call came through and
137    /// `feature` the sub-capability when one exists, so a log line says what
138    /// was refused without parsing prose. Build with
139    /// [`unsupported`](Self::unsupported) /
140    /// [`unsupported_feature`](Self::unsupported_feature).
141    #[error("{}", unsupported_message(surface, *feature, message))]
142    Unsupported {
143        /// The surface the refused call came through (`"query"`, `"kv"`, ...).
144        surface: &'static str,
145        /// The sub-capability within the surface, when one exists
146        /// (`"cas_fenced"`, `"keyword"`, `"watch"`, ...).
147        feature: Option<&'static str>,
148        /// What was asked and why it was refused.
149        message: String,
150    },
151    /// A claim-checked body failed its digest check: the blob store returned
152    /// bytes whose SHA-256 does not match the [`BodyRef`] capsule's. Never
153    /// surfaced with the fetched bytes attached, so an integrity failure can
154    /// never be mistaken for the body.
155    ///
156    /// [`BodyRef`]: laser_wire::agent::BodyRef
157    #[error("integrity: the body at `{reference}` does not match its digest")]
158    Integrity {
159        /// The capsule reference whose bytes failed verification.
160        reference: String,
161    },
162    /// An envelope signature was absent where required, enrolled to no known key,
163    /// or failed verification. The authorship and authorization gate, never
164    /// retryable.
165    #[error("signature: {0}")]
166    Signature(String),
167    /// The enrolled `ActionGovernor` rejected
168    /// the action before the effect ran. Never retryable: the policy, not the
169    /// transport, said no.
170    #[error("policy blocked: {0}")]
171    PolicyBlocked(String),
172    /// The enrolled governor paused the action on a stronger approval.
173    /// `scope` names the scope the approval must grant, so a handler can run an
174    /// `approval_gate` and re-send.
175    /// Never retryable as-is.
176    #[error("step-up required: {scope}")]
177    StepUpRequired { scope: String },
178    /// The enrolled governor held the action for later execution. Retryable by
179    /// definition: later is the point.
180    #[error("policy deferred: {0}")]
181    PolicyDeferred(String),
182    /// Capability routing found no live agent advertising `skill`. The caller
183    /// retries after the registry refreshes, or routes elsewhere.
184    #[error("no capable agent for skill: {skill}")]
185    NoCapableAgent { skill: String },
186    /// An advertised inbox route resolved a capable agent but the agent advertises
187    /// no inbox topic in its live presence, so there is nowhere to address it. The
188    /// caller waits for the agent to advertise, supplies an explicit
189    /// `InboxRoute::Fixed`, or routes elsewhere.
190    /// Never falls back to a shared topic name.
191    #[error("agent has no advertised inbox: {agent}")]
192    NoInbox { agent: String },
193    /// One physical connection may advertise one logical agent. A second agent
194    /// would overwrite connection metadata and steal the first agent's route.
195    #[error("connection already advertises agent {advertised}, cannot advertise {requested}")]
196    PresenceConflict {
197        advertised: String,
198        requested: String,
199    },
200    /// A route selected an agent claim whose live connection is not bound to the
201    /// authenticated principal the caller required.
202    #[error("agent {agent} is not bound to principal {expected} (actual: {actual:?})")]
203    RoutePrincipalMismatch {
204        agent: String,
205        expected: u32,
206        actual: Option<u32>,
207    },
208    /// A fenced write lost the fence: the held token is below the live sequence,
209    /// so a newer holder owns the task. Not alarming and never retryable by the
210    /// loser, it is the at-most-one-effective-writer gate doing its job.
211    #[error("fence violation: held {stale}, current {current}")]
212    FenceViolation { stale: u64, current: u64 },
213    /// A spend ceiling was reached. Not retryable until the ceiling is raised.
214    #[error("budget exceeded: spent {spent} of ceiling {ceiling}")]
215    BudgetExceeded { ceiling: u64, spent: u64 },
216    /// A registered run's recorded cancel intent was observed at a step
217    /// boundary. The completed steps' compensations have run. Not retryable on
218    /// the same run.
219    #[error("run cancelled: {run}")]
220    Cancelled { run: String },
221    /// The agent was quarantined and may not act. Not retryable.
222    #[error("quarantined: {agent}")]
223    Quarantined { agent: String },
224    #[error(transparent)]
225    Iggy(#[from] IggyError),
226    #[error(transparent)]
227    Id(#[from] IdError),
228    #[cfg(feature = "provenance")]
229    #[error(transparent)]
230    Provenance(#[from] ProvenanceError),
231}
232
233/// What a failed publish left behind. [`LaserError::publish_cause`] reaches
234/// the original error, so every classifier answers for it.
235#[derive(Debug, thiserror::Error)]
236#[error("publish failed to {stream}/{topic}: {source}")]
237#[non_exhaustive]
238pub struct PublishFailure {
239    #[source]
240    pub source: LaserError,
241    pub stream: String,
242    pub topic: String,
243    /// The ranges confirmed before the failure. Never replay them.
244    pub committed: Vec<SendMessagesConfirmationResponse>,
245    /// Records without a confirmation: those whose acknowledgement was lost
246    /// and those never sent. They keep the ids the attempts used, so a resend
247    /// stays deduplicated on the server.
248    pub unconfirmed: Vec<IggyMessage>,
249}
250
251// Transport failures a reconnect can clear. A failed producer send is
252// classified by its cause.
253pub(crate) fn is_transient_iggy_io_error(error: &IggyError) -> bool {
254    match error {
255        IggyError::Disconnected
256        | IggyError::TcpError
257        | IggyError::QuicError
258        | IggyError::StaleClient
259        | IggyError::NotConnected
260        | IggyError::ConnectionClosed
261        | IggyError::CannotEstablishConnection
262        | IggyError::CannotSendMessagesDueToClientDisconnection
263        | IggyError::TransientNotAccepted
264        | IggyError::TransientNotCommitted
265        | IggyError::CannotReadFile
266        | IggyError::CannotReadPartitions
267        | IggyError::PartitionNotFound(..) => true,
268        IggyError::ProducerSendFailed { cause, .. } => is_transient_iggy_io_error(cause),
269        _ => false,
270    }
271}
272
273// A publish answered `Unauthenticated` on a connection that already
274// authenticated once means the client's socket was re-dialed underneath it,
275// by its own reconnect or by a leader move, and the new node holds no session
276// for it. Recovery re-dials through the connection string, which carries the
277// auto-login credentials, so the retry runs authenticated.
278pub(crate) fn needs_reauthentication(error: &IggyError) -> bool {
279    match error {
280        IggyError::Unauthenticated => true,
281        IggyError::ProducerSendFailed { cause, .. } => needs_reauthentication(cause),
282        _ => false,
283    }
284}
285
286// The wire crate's codec failures map onto the SDK's own variants so every
287// call site keeps its error shape after a `?`.
288impl From<DecodeError> for LaserError {
289    fn from(error: DecodeError) -> Self {
290        match error {
291            DecodeError::MissingPayload(message) => LaserError::Config(message),
292            other => LaserError::Codec(other.to_string()),
293        }
294    }
295}
296
297impl From<InvalidError> for LaserError {
298    fn from(error: InvalidError) -> Self {
299        LaserError::Invalid(error.0)
300    }
301}
302
303impl From<laser_wire::checkpoint::CheckpointError> for LaserError {
304    fn from(error: laser_wire::checkpoint::CheckpointError) -> Self {
305        Self::Checkpoint(Box::new(error))
306    }
307}
308
309// An envelope rejected by the AGDX validity matrix at publish time: validation,
310// so the caller fixes the envelope, never retries.
311impl From<laser_wire::agent::ValidateError> for LaserError {
312    fn from(error: laser_wire::agent::ValidateError) -> Self {
313        LaserError::Invalid(error.to_string())
314    }
315}
316
317// The canonical surface-agnostic reply a server sends for a command code it does
318// not handle. Map its `ResultCode` onto the closest typed variant so the
319// classifier methods still work (the common case is `Unsupported`).
320impl From<CommandError> for LaserError {
321    fn from(error: CommandError) -> Self {
322        match error.code {
323            ResultCode::Unsupported => LaserError::Unsupported {
324                surface: "managed",
325                feature: None,
326                message: error.message,
327            },
328            ResultCode::InvalidArgument => LaserError::Invalid(error.message),
329            _ => LaserError::Command(error),
330        }
331    }
332}
333
334/// Decode a managed reply of type `R`, falling back to the surface-agnostic
335/// [`CommandError`] when the typed reply does not decode. A server that did not
336/// handle the command code answers with a `CommandError` rather than this
337/// surface's reply, so without the fallback that wrong-shape reply would surface
338/// as an opaque codec error instead of a clean classification. The two shapes
339/// are disjoint (an enum-tagged reply versus a `{code, message}` map), so the
340/// fallback never misfires on a genuine reply.
341#[cfg(feature = "streaming")]
342pub(crate) fn decode_managed_reply<R: serde::de::DeserializeOwned>(
343    payload: &[u8],
344) -> Result<R, LaserError> {
345    match laser_wire::framing::decode_named::<R>(payload) {
346        Ok(reply) => Ok(reply),
347        Err(typed_error) => match laser_wire::framing::decode_named::<CommandError>(payload) {
348            Ok(command_error) => Err(command_error.into()),
349            Err(_) => Err(LaserError::Codec(format!("decode reply: {typed_error}"))),
350        },
351    }
352}
353
354impl LaserError {
355    pub fn publish_cause(&self) -> &Self {
356        match self {
357            Self::PublishFailed(failure) => failure.source.publish_cause(),
358            error => error,
359        }
360    }
361    /// A handler returns this to reject a message permanently: the reliable
362    /// consumer dead-letters it immediately instead of retrying.
363    pub fn rejected(reason: impl Into<String>) -> Self {
364        Self::Rejected(reason.into())
365    }
366
367    /// An unsupported-surface refusal: the whole `surface` is absent on the
368    /// connected deployment (e.g. any managed call against Apache Iggy).
369    pub fn unsupported(surface: &'static str, message: impl Into<String>) -> Self {
370        Self::Unsupported {
371            surface,
372            feature: None,
373            message: message.into(),
374        }
375    }
376
377    /// An unsupported-feature refusal: the surface is served but this
378    /// sub-capability is not advertised (e.g. `kv.cas_fenced`, `query.keyword`).
379    pub fn unsupported_feature(
380        surface: &'static str,
381        feature: &'static str,
382        message: impl Into<String>,
383    ) -> Self {
384        Self::Unsupported {
385            surface,
386            feature: Some(feature),
387            message: message.into(),
388        }
389    }
390
391    /// Whether retrying the same call can succeed. Managed failures follow the
392    /// canonical [`ResultCode`] classifier, while transport, handler, routing,
393    /// timeout, and deferred-policy failures retain their local semantics.
394    pub fn is_retryable(&self) -> bool {
395        match self.publish_cause() {
396            Self::PublishFailed(failure) => failure.source.is_retryable(),
397            Self::Rejected(_)
398            | Self::Unsupported { .. }
399            | Self::Invalid(_)
400            | Self::Codec(_)
401            | Self::Protocol(_)
402            | Self::Config(_)
403            | Self::HandlerConfig(_)
404            | Self::StateStore(_)
405            | Self::Integrity { .. }
406            | Self::NoStream
407            | Self::PolicyBlocked(_)
408            | Self::StepUpRequired { .. }
409            | Self::NoRespondTopic
410            | Self::PresenceConflict { .. }
411            | Self::RoutePrincipalMismatch { .. }
412            | Self::Signature(_)
413            | Self::FenceViolation { .. }
414            | Self::BudgetExceeded { .. }
415            | Self::Cancelled { .. }
416            | Self::Quarantined { .. }
417            | Self::Authz(_)
418            | Self::FilterFault { .. }
419            | Self::FilterOversizedRecord { .. }
420            | Self::Id(_) => false,
421            #[cfg(feature = "provenance")]
422            Self::Provenance(_) => false,
423            Self::Query(error) => ResultCode::from(error).is_retryable(),
424            Self::Command(error) => error.code.is_retryable(),
425            Self::Kv(error) => ResultCode::from(error).is_retryable(),
426            Self::Fork(error) => ResultCode::from(error).is_retryable(),
427            Self::Session(error) => ResultCode::from(error).is_retryable(),
428            Self::Graph(error) => ResultCode::from(error).is_retryable(),
429            Self::Checkpoint(error) => ResultCode::from(error.as_ref()).is_retryable(),
430            Self::Filter(error) => error.code.is_retryable(),
431            Self::ConsumerGroupSetup { source, .. } => source.is_retryable(),
432            Self::Iggy(error) => is_transient_iggy_io_error(error) || needs_reauthentication(error),
433            Self::Handler(_)
434            | Self::Timeout(_)
435            | Self::PolicyDeferred(_)
436            | Self::NoCapableAgent { .. }
437            | Self::NoInbox { .. } => true,
438            Self::AmbiguousMutation(_) => false,
439        }
440    }
441
442    /// Whether the failure was transient: the store was momentarily out of reach,
443    /// a connection was refused or dropped, or a concurrency conflict was
444    /// aborted. The identical request may succeed on a later attempt, so this is
445    /// the flag a retry loop keys on when it wants only the recoverable class
446    /// rather than everything [`is_retryable`](Self::is_retryable) admits.
447    pub fn is_unavailable(&self) -> bool {
448        matches!(
449            self.code(),
450            ResultCode::Unavailable | ResultCode::TargetUnavailable
451        )
452    }
453
454    /// Whether the connected managed backend declined a conditional write because it
455    /// does not own that operation's mutation partition.
456    pub fn is_not_leader(&self) -> bool {
457        matches!(
458            self.publish_cause(),
459            Self::Kv(KvError::NotLeader) | Self::Fork(ForkError::NotLeader)
460        )
461    }
462
463    /// Whether the failure means the connected infrastructure (or backend)
464    /// does not provide the feature at all. Permanent: do not retry, gate the
465    /// code path instead.
466    pub fn is_unsupported(&self) -> bool {
467        if matches!(self.publish_cause(), Self::Command(error) if error.code == ResultCode::Unsupported)
468        {
469            return true;
470        }
471        matches!(
472            self.publish_cause(),
473            Self::Unsupported { .. }
474                | Self::Query(QueryError::Unsupported(_))
475                | Self::Kv(KvError::Unsupported(_))
476                | Self::Fork(ForkError::Unsupported(_))
477                | Self::Graph(laser_wire::graph::GraphError::Unsupported(_))
478        ) || self.filter_reason() == Some(FilterErrorReason::Unsupported)
479    }
480
481    /// Whether the failure names a missing resource (index, fork). Distinct
482    /// from a backend being down: the call worked, the thing is not there.
483    pub fn is_not_found(&self) -> bool {
484        if matches!(self.publish_cause(), Self::Command(error) if error.code == ResultCode::NotFound)
485        {
486            return true;
487        }
488        matches!(
489            self.publish_cause(),
490            Self::Query(QueryError::IndexNotFound(_) | QueryError::ForkNotFound(_))
491                | Self::Fork(ForkError::NotFound(_))
492        ) || self.is_stream_or_topic_not_found()
493            || matches!(self.iggy_cause(), Some(IggyError::ResourceNotFound(_)))
494            || self.filter_reason() == Some(FilterErrorReason::NotFound)
495            || matches!(
496                self.publish_cause(),
497                Self::Checkpoint(error)
498                    if matches!(error.as_ref(), laser_wire::checkpoint::CheckpointError::NotFound)
499            )
500    }
501
502    /// Whether the failure is wire-version skew between this SDK and the
503    /// connected LaserData Cloud (the envelope's `v` was not accepted). Permanent for
504    /// this client build: upgrade or downshift instead of retrying.
505    pub fn is_version_skew(&self) -> bool {
506        if matches!(self.publish_cause(), Self::Command(error) if error.code == ResultCode::VersionSkew)
507        {
508            return true;
509        }
510        matches!(
511            self.publish_cause(),
512            Self::Query(QueryError::Version { .. })
513                | Self::Kv(KvError::Version { .. })
514                | Self::Fork(ForkError::Version { .. })
515        ) || self.filter_reason() == Some(FilterErrorReason::VersionSkew)
516            || matches!(
517                self.publish_cause(),
518                Self::Checkpoint(error)
519                    if matches!(error.as_ref(), laser_wire::checkpoint::CheckpointError::Version { .. })
520            )
521    }
522
523    /// Whether a compare-and-swap lost its precondition (the key changed under
524    /// it, or an `expect_absent` create lost a race). Not a failure to retry
525    /// blindly: re-read the current value and version, recompute, and CAS again.
526    pub fn is_version_conflict(&self) -> bool {
527        matches!(
528            self.publish_cause(),
529            Self::Kv(KvError::VersionConflict { .. })
530        )
531    }
532
533    /// Whether a managed mutation may already have reached the server. A
534    /// generic retry loop must stop: recovery depends on the operation. In
535    /// particular, an ambiguous lease acquisition must wait through its
536    /// requested TTL before a fresh acquisition, because an applied grant can
537    /// otherwise conflict with itself and the reply carries no token to renew
538    /// or release it.
539    pub fn is_ambiguous_mutation(&self) -> bool {
540        matches!(self.publish_cause(), Self::AmbiguousMutation(_))
541    }
542
543    /// Whether a read-consistency barrier could not be met: a query projector
544    /// or key-value fold has not yet applied the required source position.
545    /// Retryable: the read model is catching up.
546    pub fn is_stale(&self) -> bool {
547        if matches!(self.publish_cause(), Self::Command(error) if error.code == ResultCode::Stale) {
548            return true;
549        }
550        matches!(
551            self.publish_cause(),
552            Self::Query(QueryError::Stale { .. }) | Self::Kv(KvError::Stale { .. })
553        )
554    }
555
556    /// Whether the connected server refused the operation for missing
557    /// permissions (the substrate's RBAC: global permissions plus per-stream
558    /// and per-topic grants). The principal exists and authenticated, the
559    /// grant does not. The fix is an admin conversation, not a retry: ask for
560    /// the stream or topic permission the failing verb needs (read, send,
561    /// create), or point the client at a stream the principal can use.
562    pub fn is_permission_denied(&self) -> bool {
563        if matches!(self.publish_cause(), Self::Command(error) if matches!(error.code, ResultCode::Forbidden | ResultCode::Unauthenticated))
564        {
565            return true;
566        }
567        matches!(self.publish_cause(), Self::RoutePrincipalMismatch { .. })
568            || matches!(
569                self.iggy_cause(),
570                Some(IggyError::Unauthorized | IggyError::Unauthenticated)
571            )
572            || matches!(
573                self.filter_reason(),
574                Some(FilterErrorReason::Forbidden | FilterErrorReason::Unauthenticated)
575            )
576            || matches!(
577                self.publish_cause(),
578                Self::Checkpoint(error)
579                    if matches!(error.as_ref(), laser_wire::checkpoint::CheckpointError::Unauthorized)
580            )
581    }
582
583    /// Whether the target stream or topic does not exist FOR THIS PRINCIPAL.
584    /// Under RBAC an existing stream the principal cannot see answers exactly
585    /// like a missing one, so treat "not found" and "not permitted" as one
586    /// user-facing question: does this principal have the stream, with the
587    /// grant the verb needs? Especially relevant on deployments with
588    /// server-managed dynamic streams, where the stream may simply not exist
589    /// yet for this principal.
590    pub fn is_stream_or_topic_not_found(&self) -> bool {
591        matches!(
592            self.iggy_cause(),
593            Some(
594                IggyError::StreamIdNotFound(_)
595                    | IggyError::StreamNameNotFound(_)
596                    | IggyError::TopicIdNotFound(_, _)
597                    | IggyError::TopicNameNotFound(_, _)
598            )
599        )
600    }
601
602    fn iggy_cause(&self) -> Option<&IggyError> {
603        let Self::Iggy(error) = self.publish_cause() else {
604            return None;
605        };
606        let mut cause = error;
607        while let IggyError::ProducerSendFailed { cause: nested, .. } = cause {
608            cause = nested;
609        }
610        Some(cause)
611    }
612
613    /// Whether capability routing found no live agent for the skill.
614    pub fn is_no_capable_agent(&self) -> bool {
615        matches!(self.publish_cause(), Self::NoCapableAgent { .. })
616    }
617
618    /// Whether a revocable lease was lost (expired, released, or re-acquired by another
619    /// holder). For a fenced write, see [`is_fence_violation`](Self::is_fence_violation).
620    pub fn is_lease_lost(&self) -> bool {
621        matches!(self.publish_cause(), Self::Kv(KvError::LeaseLost))
622    }
623
624    /// Whether a fenced write lost the fence (a stale holder stepped aside). Not
625    /// alarming, never retryable by the loser.
626    pub fn is_fence_violation(&self) -> bool {
627        matches!(self.publish_cause(), Self::FenceViolation { .. })
628    }
629
630    /// Whether a spend ceiling was reached.
631    pub fn is_budget_exceeded(&self) -> bool {
632        matches!(self.publish_cause(), Self::BudgetExceeded { .. })
633    }
634
635    /// Whether the agent is quarantined.
636    pub fn is_quarantined(&self) -> bool {
637        matches!(self.publish_cause(), Self::Quarantined { .. })
638    }
639
640    /// The Apache Iggy error code of the failure, when an Iggy error caused it.
641    /// Reads through publish wrapping and a failed producer send, like the
642    /// classifiers. Another SDK variant keeps its own classification.
643    pub fn iggy_error_code(&self) -> Option<u32> {
644        self.iggy_cause().map(IggyError::as_code)
645    }
646
647    /// The typed consumer-filter cause, when the failure is one.
648    pub fn filter_reason(&self) -> Option<FilterErrorReason> {
649        match self.publish_cause() {
650            Self::Filter(error) => Some(error.reason),
651            _ => None,
652        }
653    }
654
655    /// This failure's place in the unified [`ResultCode`] space. A managed
656    /// surface error projects onto its surface code, and a client-side failure maps
657    /// to the closest code. Lets a caller branch on one dictionary instead of
658    /// matching every variant, and mirrors the HTTP status the same error gets
659    /// on the management surface.
660    pub fn code(&self) -> ResultCode {
661        match self.publish_cause() {
662            Self::PublishFailed(failure) => failure.source.code(),
663            Self::Iggy(_) if matches!(self.iggy_cause(), Some(IggyError::ResourceNotFound(_))) => {
664                ResultCode::NotFound
665            }
666            Self::Iggy(_) if self.is_stream_or_topic_not_found() => ResultCode::NotFound,
667            Self::Iggy(_) if matches!(self.iggy_cause(), Some(IggyError::Unauthorized)) => {
668                ResultCode::Forbidden
669            }
670            Self::Iggy(_) if matches!(self.iggy_cause(), Some(IggyError::Unauthenticated)) => {
671                ResultCode::Unauthenticated
672            }
673            Self::Iggy(error) if is_transient_iggy_io_error(error) => ResultCode::Unavailable,
674            Self::Filter(error) => error.code,
675            Self::Command(error) => error.code,
676            Self::FilterFault { .. } => ResultCode::InvalidArgument,
677            Self::FilterOversizedRecord { .. } => ResultCode::TooLarge,
678            Self::ConsumerGroupSetup { source, .. } => source.code(),
679            Self::Query(error) => ResultCode::from(error),
680            Self::Kv(error) => ResultCode::from(error),
681            Self::Fork(error) => ResultCode::from(error),
682            Self::Session(error) => ResultCode::from(error),
683            Self::Graph(error) => ResultCode::from(error),
684            Self::Checkpoint(error) => ResultCode::from(error.as_ref()),
685            Self::Unsupported { .. } | Self::NoStream | Self::NoRespondTopic => {
686                ResultCode::Unsupported
687            }
688            Self::Invalid(_) | Self::Config(_) | Self::HandlerConfig(_) | Self::Rejected(_) => {
689                ResultCode::InvalidArgument
690            }
691            // A fenced write that lost the fence is a lost race, like a
692            // compare-and-swap conflict.
693            Self::FenceViolation { .. } | Self::PresenceConflict { .. } => ResultCode::Conflict,
694            // An unverified signature means the author's identity is not
695            // established (unauthenticated). A quarantined agent is authenticated
696            // but not permitted to act (forbidden).
697            Self::Signature(_) => ResultCode::Unauthenticated,
698            Self::Quarantined { .. }
699            | Self::PolicyBlocked(_)
700            | Self::RoutePrincipalMismatch { .. } => ResultCode::Forbidden,
701            Self::StepUpRequired { .. } => ResultCode::StepUpRequired,
702            // A digest mismatch is served-data corruption, a backend fault.
703            Self::Integrity { .. } => ResultCode::Backend,
704            Self::NoCapableAgent { .. } | Self::NoInbox { .. } => ResultCode::NotFound,
705            // A transport timeout is not read-model staleness (that is a typed
706            // query or KV stale reply), so it classifies as a backend failure,
707            // keeping `code()` and `is_stale()` in agreement.
708            _ => ResultCode::Backend,
709        }
710    }
711}
712
713// A remote-safe rendering of a failure, for the bridge HTTP routers. The
714// `Display` text names streams, topics, hosts, and transport detail, which an
715// unauthenticated JSON-RPC caller has no business seeing, so the wire carries
716// only the classification and the detail stays in the local log.
717#[cfg(any(feature = "a2a-bridge", feature = "mcp-bridge"))]
718pub(crate) fn public_error_message(error: &LaserError) -> &'static str {
719    match error.code() {
720        ResultCode::InvalidArgument => "invalid request",
721        ResultCode::Unsupported => "unsupported operation",
722        ResultCode::Conflict => "conflict",
723        ResultCode::Unauthenticated => "unauthenticated",
724        ResultCode::Forbidden => "forbidden",
725        ResultCode::StepUpRequired => "step-up authorization required",
726        ResultCode::NotFound => "not found",
727        _ => "internal error",
728    }
729}
730
731// The `Unsupported` display: a surface refusal reads `unsupported: kv: ...`,
732// a feature refusal names the capability that would serve it and where, so
733// the fix ships inside the message.
734fn unsupported_message(surface: &str, feature: Option<&str>, message: &str) -> String {
735    match feature {
736        // An accessor that is its own capability (watch) reads once, not twice.
737        Some(feature) if feature == surface => format!(
738            "unsupported: {surface}: {message} \
739             (the `{feature}` capability serves this on LaserData Cloud)"
740        ),
741        Some(feature) => format!(
742            "unsupported: {surface}.{feature}: {message} \
743             (the `{feature}` capability serves this on LaserData Cloud)"
744        ),
745        None => format!("unsupported: {surface}: {message}"),
746    }
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    #[test]
754    fn given_managed_command_errors_when_classified_then_should_preserve_the_original_codes() {
755        for (code, retryable, unavailable, denied, missing, skew) in [
756            (ResultCode::Conflict, false, false, false, false, false),
757            (
758                ResultCode::TargetUnavailable,
759                true,
760                true,
761                false,
762                false,
763                false,
764            ),
765            (ResultCode::Forbidden, false, false, true, false, false),
766            (ResultCode::NotFound, false, false, false, true, false),
767            (ResultCode::VersionSkew, false, false, false, false, true),
768            (
769                ResultCode::Unrecognized(42),
770                false,
771                false,
772                false,
773                false,
774                false,
775            ),
776        ] {
777            let error = LaserError::from(CommandError::new(code, "refused"));
778            assert_eq!(error.code(), code);
779            assert_eq!(error.is_retryable(), retryable);
780            assert_eq!(error.is_unavailable(), unavailable);
781            assert_eq!(error.is_permission_denied(), denied);
782            assert_eq!(error.is_not_found(), missing);
783            assert_eq!(error.is_version_skew(), skew);
784        }
785    }
786
787    #[test]
788    fn given_a_failed_producer_send_when_classified_then_should_use_its_cause() {
789        for (cause, code, retryable, denied, missing) in [
790            (
791                IggyError::Unauthorized,
792                ResultCode::Forbidden,
793                false,
794                true,
795                false,
796            ),
797            (
798                IggyError::Unauthenticated,
799                ResultCode::Unauthenticated,
800                true,
801                true,
802                false,
803            ),
804            (
805                IggyError::StreamNameNotFound("deleted".into()),
806                ResultCode::NotFound,
807                false,
808                false,
809                true,
810            ),
811            (
812                IggyError::Disconnected,
813                ResultCode::Unavailable,
814                true,
815                false,
816                false,
817            ),
818            (
819                IggyError::InvalidFormat,
820                ResultCode::Backend,
821                false,
822                false,
823                false,
824            ),
825        ] {
826            let error = LaserError::Iggy(IggyError::ProducerSendFailed {
827                cause: Box::new(cause),
828                failed: std::sync::Arc::new(Vec::new()),
829                committed: std::sync::Arc::new(Vec::new()),
830                stream_name: "s".into(),
831                topic_name: "t".into(),
832            });
833            assert_eq!(error.code(), code);
834            assert_eq!(error.is_retryable(), retryable);
835            assert_eq!(error.is_permission_denied(), denied);
836            assert_eq!(error.is_not_found(), missing);
837            assert_eq!(error.is_stream_or_topic_not_found(), missing);
838        }
839    }
840
841    #[test]
842    fn given_an_iggy_failure_when_reading_its_code_then_should_unwrap_the_send_cause() {
843        let send = LaserError::Iggy(IggyError::ProducerSendFailed {
844            cause: Box::new(IggyError::Unauthorized),
845            failed: std::sync::Arc::new(Vec::new()),
846            committed: std::sync::Arc::new(Vec::new()),
847            stream_name: "s".into(),
848            topic_name: "t".into(),
849        });
850        assert_eq!(
851            send.iggy_error_code(),
852            Some(IggyError::Unauthorized.as_code())
853        );
854        assert_eq!(
855            LaserError::Iggy(IggyError::Disconnected).iggy_error_code(),
856            Some(IggyError::Disconnected.as_code())
857        );
858        assert_eq!(
859            LaserError::Handler("not an Iggy failure".to_owned()).iggy_error_code(),
860            None
861        );
862    }
863
864    #[test]
865    fn given_a_deterministic_handler_config_error_when_classified_then_should_not_retry() {
866        let error = LaserError::HandlerConfig("agent stopped before ready".to_owned());
867        assert!(
868            !error.is_retryable(),
869            "a deterministic wiring failure must not burn the retry budget"
870        );
871        assert_eq!(error.code(), ResultCode::InvalidArgument);
872    }
873
874    #[test]
875    fn given_a_transient_handler_error_when_classified_then_should_retry() {
876        assert!(LaserError::Handler("upstream hiccup".to_owned()).is_retryable());
877    }
878
879    #[test]
880    fn given_not_leader_when_classified_then_should_be_retryable_and_typed() {
881        let error = LaserError::from(KvError::NotLeader);
882        assert!(error.is_retryable());
883        assert!(error.is_not_leader());
884        assert_eq!(error.code(), ResultCode::Unavailable);
885    }
886
887    #[test]
888    fn given_a_backend_fault_when_classified_then_should_not_retry_blindly() {
889        for error in [
890            LaserError::from(QueryError::Backend("corrupt row".to_owned())),
891            LaserError::from(KvError::Backend("constraint missing".to_owned())),
892            LaserError::from(ForkError::Backend("invalid schema".to_owned())),
893        ] {
894            assert!(!error.is_retryable(), "{error} must be permanent");
895            assert_eq!(error.code(), ResultCode::Backend);
896        }
897    }
898
899    #[test]
900    fn given_an_ambiguous_mutation_when_classified_then_should_require_specific_recovery() {
901        let error = LaserError::AmbiguousMutation("lease reply was lost".to_owned());
902        assert!(error.is_ambiguous_mutation());
903        assert!(!error.is_retryable());
904    }
905
906    #[test]
907    fn given_a_stale_kv_read_when_classified_then_should_agree_with_the_result_code() {
908        let error = LaserError::from(KvError::Stale {
909            required: laser_wire::mutation::MutationPosition {
910                topic_generation: 1,
911                partition: 0,
912                offset: 9,
913            },
914        });
915        assert!(error.is_stale());
916        assert_eq!(error.code(), ResultCode::Stale);
917    }
918
919    // A transient outcome classifies as retryable on every surface, and it keeps
920    // its own code so a caller can tell "retry me" from an unrecoverable fault
921    // without reading the message.
922    #[test]
923    fn given_a_temporarily_unavailable_surface_when_classified_then_should_retry() {
924        for error in [
925            LaserError::from(QueryError::Unavailable("dropped".to_owned())),
926            LaserError::from(KvError::Unavailable("dropped".to_owned())),
927            LaserError::from(ForkError::Unavailable("dropped".to_owned())),
928        ] {
929            assert!(error.is_retryable(), "{error} must be retryable");
930            assert!(error.is_unavailable());
931            assert_eq!(error.code(), ResultCode::Unavailable);
932            assert!(error.code().is_retryable());
933            assert!(!error.is_unsupported());
934        }
935    }
936}