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#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum LaserError {
26 #[error(transparent)]
29 PublishFailed(Box<PublishFailure>),
30 #[error("handler error: {0}")]
35 Handler(String),
36 #[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 #[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 #[error("query: {0}")]
66 Query(#[from] QueryError),
67 #[error("kv: {0}")]
69 Kv(#[from] KvError),
70 #[error("fork: {0}")]
72 Fork(#[from] ForkError),
73 #[error("session: {0}")]
75 Session(#[from] laser_wire::session::SessionError),
76 #[error("graph: {0}")]
78 Graph(#[from] laser_wire::graph::GraphError),
79 #[error("authz: {0}")]
81 Authz(#[from] laser_wire::authz::AuthzError),
82 #[error("filter: {0}")]
85 Filter(#[from] laser_wire::filter::FilterError),
86 #[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 #[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 #[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 #[error("checkpoint: {0}")]
116 Checkpoint(Box<laser_wire::checkpoint::CheckpointError>),
117 #[error("codec: {0}")]
120 Codec(String),
121 #[error("invalid: {0}")]
124 Invalid(String),
125 #[error("protocol: {0}")]
128 Protocol(String),
129 #[error("managed command: {}", .0.message)]
131 Command(CommandError),
132 #[error("{}", unsupported_message(surface, *feature, message))]
142 Unsupported {
143 surface: &'static str,
145 feature: Option<&'static str>,
148 message: String,
150 },
151 #[error("integrity: the body at `{reference}` does not match its digest")]
158 Integrity {
159 reference: String,
161 },
162 #[error("signature: {0}")]
166 Signature(String),
167 #[error("policy blocked: {0}")]
171 PolicyBlocked(String),
172 #[error("step-up required: {scope}")]
177 StepUpRequired { scope: String },
178 #[error("policy deferred: {0}")]
181 PolicyDeferred(String),
182 #[error("no capable agent for skill: {skill}")]
185 NoCapableAgent { skill: String },
186 #[error("agent has no advertised inbox: {agent}")]
192 NoInbox { agent: String },
193 #[error("connection already advertises agent {advertised}, cannot advertise {requested}")]
196 PresenceConflict {
197 advertised: String,
198 requested: String,
199 },
200 #[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 #[error("fence violation: held {stale}, current {current}")]
212 FenceViolation { stale: u64, current: u64 },
213 #[error("budget exceeded: spent {spent} of ceiling {ceiling}")]
215 BudgetExceeded { ceiling: u64, spent: u64 },
216 #[error("run cancelled: {run}")]
220 Cancelled { run: String },
221 #[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#[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 pub committed: Vec<SendMessagesConfirmationResponse>,
245 pub unconfirmed: Vec<IggyMessage>,
249}
250
251pub(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
273pub(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
286impl 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
309impl 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
317impl 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#[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 pub fn rejected(reason: impl Into<String>) -> Self {
364 Self::Rejected(reason.into())
365 }
366
367 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 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 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 pub fn is_unavailable(&self) -> bool {
448 matches!(
449 self.code(),
450 ResultCode::Unavailable | ResultCode::TargetUnavailable
451 )
452 }
453
454 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 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 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 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 pub fn is_version_conflict(&self) -> bool {
527 matches!(
528 self.publish_cause(),
529 Self::Kv(KvError::VersionConflict { .. })
530 )
531 }
532
533 pub fn is_ambiguous_mutation(&self) -> bool {
540 matches!(self.publish_cause(), Self::AmbiguousMutation(_))
541 }
542
543 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 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 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 pub fn is_no_capable_agent(&self) -> bool {
615 matches!(self.publish_cause(), Self::NoCapableAgent { .. })
616 }
617
618 pub fn is_lease_lost(&self) -> bool {
621 matches!(self.publish_cause(), Self::Kv(KvError::LeaseLost))
622 }
623
624 pub fn is_fence_violation(&self) -> bool {
627 matches!(self.publish_cause(), Self::FenceViolation { .. })
628 }
629
630 pub fn is_budget_exceeded(&self) -> bool {
632 matches!(self.publish_cause(), Self::BudgetExceeded { .. })
633 }
634
635 pub fn is_quarantined(&self) -> bool {
637 matches!(self.publish_cause(), Self::Quarantined { .. })
638 }
639
640 pub fn iggy_error_code(&self) -> Option<u32> {
644 self.iggy_cause().map(IggyError::as_code)
645 }
646
647 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 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 Self::FenceViolation { .. } | Self::PresenceConflict { .. } => ResultCode::Conflict,
694 Self::Signature(_) => ResultCode::Unauthenticated,
698 Self::Quarantined { .. }
699 | Self::PolicyBlocked(_)
700 | Self::RoutePrincipalMismatch { .. } => ResultCode::Forbidden,
701 Self::StepUpRequired { .. } => ResultCode::StepUpRequired,
702 Self::Integrity { .. } => ResultCode::Backend,
704 Self::NoCapableAgent { .. } | Self::NoInbox { .. } => ResultCode::NotFound,
705 _ => ResultCode::Backend,
709 }
710 }
711}
712
713#[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
731fn unsupported_message(surface: &str, feature: Option<&str>, message: &str) -> String {
735 match feature {
736 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 #[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}