1use super::group_permissions::GroupMutablePermissionsError;
2use super::mls_sync::GroupMessageProcessingError;
3use super::summary::SyncSummary;
4use super::{intents::IntentError, validated_commit::CommitValidationError};
5use crate::identity::IdentityError;
6use crate::mls_store::MlsStoreError;
7use crate::{
8 identity_updates::{IdentityUpdateError, InstallationDiffError},
9 intents::ProcessIntentError,
10};
11use openmls::{
12 error::LibraryError,
13 group::{
14 CommitToPendingProposalsError, CreateGroupContextExtProposalError, ProposalError,
15 ProposeAddMemberError, ProposeRemoveMemberError,
16 },
17 prelude::{BasicCredentialError, Error as TlsCodecError},
18};
19use thiserror::Error;
20use xmtp_common::ErrorCode;
21use xmtp_common::retry::RetryableError;
22use xmtp_content_types::CodecError;
23use xmtp_cryptography::signature::IdentifierValidationError;
24use xmtp_db::NotFound;
25use xmtp_db::sql_key_store;
26use xmtp_mls_group_keys::mls_ext::payload_encryption::{UnwrapPayloadError, WrapPayloadError};
27use xmtp_mls_group_state::group_metadata::GroupMetadataError;
28use xmtp_mls_group_state::group_mutable_metadata::GroupMutableMetadataError;
29use xmtp_proto::types::GroupId;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct FailedInstallationIds(pub Vec<Vec<u8>>);
34
35impl std::fmt::Display for FailedInstallationIds {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 let encoded: Vec<String> = self.0.iter().map(hex::encode).collect();
38 write!(f, "{}", encoded.join(","))
39 }
40}
41
42#[derive(Debug, Error, ErrorCode)]
43pub enum GroupError {
44 #[error("reserved transcript content type")]
46 ReservedTranscriptContentType,
47 #[error("message id belongs to another sender inbox")]
49 MessageSenderConflict,
50 #[error(transparent)]
51 #[error_code(inherit)]
52 OutgoingPreparation(#[from] super::mls_sync::publish::OutgoingPreparationError),
53 #[error(transparent)]
54 #[error_code(inherit)]
55 NotFound(#[from] NotFound),
56 #[error("Max user limit exceeded.")]
60 UserLimitExceeded,
61 #[error("SequenceId not found in local db")]
66 MissingSequenceId,
67 #[error("Addresses not found {0:?}")]
71 AddressNotFound(Vec<String>),
72 #[error("api error: {0}")]
76 WrappedApi(#[from] xmtp_api::ApiError),
77 #[error("invalid group membership")]
81 InvalidGroupMembership,
82 #[error("consent act: {0}")]
86 #[error_code(inherit)]
87 ConsentAct(#[from] xmtp_db::consent_record::ConsentActError),
88 #[error(transparent)]
92 LeaveCantProcessed(#[from] GroupLeaveValidationError),
93 #[error("storage error: {0}")]
97 Storage(#[from] xmtp_db::StorageError),
98 #[error("intent error: {0}")]
102 Intent(#[from] IntentError),
103 #[error("create message: {0}")]
107 CreateMessage(#[from] openmls::prelude::CreateMessageError),
108 #[error("TLS Codec error: {0}")]
112 TlsError(#[from] TlsCodecError),
113 #[error("group create: {0}")]
117 GroupCreate(#[from] openmls::group::NewGroupError<sql_key_store::SqlKeyStoreError>),
118 #[error("self update: {0}")]
122 SelfUpdate(#[from] openmls::group::SelfUpdateError<sql_key_store::SqlKeyStoreError>),
123 #[error("welcome error: {0}")]
127 WelcomeError(#[from] openmls::prelude::WelcomeError<sql_key_store::SqlKeyStoreError>),
128 #[error("Invalid extension {0}")]
132 InvalidExtension(#[from] openmls::prelude::InvalidExtensionError),
133 #[error("Invalid signature: {0}")]
137 Signature(#[from] openmls::prelude::SignatureError),
138 #[error("identity update: {0}")]
142 IdentityUpdate(#[from] IdentityUpdateError),
143 #[error("connection blocked: {0}")]
148 Blocked(#[from] crate::server_configuration::BlockedConnection),
149 #[error("receive error: {0}")]
153 ReceiveError(#[from] GroupMessageProcessingError),
154 #[error(transparent)]
158 AddressValidation(#[from] IdentifierValidationError),
159 #[error("Public Keys {0:?} are not valid ed25519 public keys")]
163 InvalidPublicKeys(Vec<Vec<u8>>),
164 #[error("Commit validation error {0}")]
168 CommitValidation(#[from] CommitValidationError),
169 #[error("identity error: {0}")]
173 Identity(#[from] IdentityError),
174 #[error("conversion error: {0}")]
178 ConversionError(#[from] xmtp_proto::ConversionError),
179 #[error("crypto error: {0}")]
183 CryptoError(#[from] openmls::prelude::CryptoError),
184 #[error("create group context proposal error: {0}")]
188 CreateGroupContextExtProposalError(
189 #[from] CreateGroupContextExtProposalError<sql_key_store::SqlKeyStoreError>,
190 ),
191 #[error("propose add member error: {0}")]
195 ProposeAddMember(#[from] ProposeAddMemberError<sql_key_store::SqlKeyStoreError>),
196 #[error("propose remove member error: {0}")]
200 ProposeRemoveMember(#[from] ProposeRemoveMemberError<sql_key_store::SqlKeyStoreError>),
201 #[error("proposal error: {0}")]
205 Proposal(#[from] ProposalError<sql_key_store::SqlKeyStoreError>),
206 #[error("commit to pending proposals error: {0}")]
210 CommitToPendingProposals(
211 #[from] CommitToPendingProposalsError<sql_key_store::SqlKeyStoreError>,
212 ),
213 #[error("merge pending commit error: {0}")]
217 MergePendingCommit(
218 #[from] openmls::group::MergePendingCommitError<sql_key_store::SqlKeyStoreError>,
219 ),
220 #[error("Proposals not supported: {0}")]
224 ProposalsNotSupported(String),
225 #[error("min_version {requested} exceeds own pkg_version {own}")]
230 MinVersionExceedsOwnVersion { requested: String, own: String },
231 #[error("min_version {requested} would downgrade existing floor {current}")]
236 MinVersionDowngrade { requested: String, current: String },
237 #[error("invalid min_version {value:?}: {reason}")]
241 InvalidMinVersion { value: String, reason: String },
242 #[error("component source error: {0}")]
247 ComponentSource(#[from] xmtp_mls_group_state::app_data::component_source::ComponentSourceError),
248 #[error("metadata field error: {0}")]
252 MetadataField(#[from] xmtp_mls_group_state::app_data::fields::FieldError),
253 #[error("app data commit error: {0}")]
260 AppDataCommit(#[from] super::app_data::GroupAppDataError<sql_key_store::SqlKeyStoreError>),
261 #[error("bootstrap synthesis error: {0}")]
266 BootstrapSynthesis(#[from] super::app_data::migration::BootstrapSynthesisError),
267 #[error("Credential error")]
271 CredentialError(#[from] BasicCredentialError),
272 #[error("LeafNode error")]
276 LeafNodeError(#[from] LibraryError),
277 #[error("Installation diff error: {0}")]
281 InstallationDiff(#[from] InstallationDiffError),
282 #[error("PSKs are not support")]
286 NoPSKSupport,
287 #[error("sql key store error: {0}")]
291 SqlKeyStore(#[from] sql_key_store::SqlKeyStoreError),
292 #[error("Sync failed to wait for intent: {}", _0)]
296 SyncFailedToWait(#[source] Box<SyncSummary>),
297 #[error(transparent)]
299 #[error_code(inherit)]
300 StreamBarrier(#[from] crate::subscriptions::barrier::BarrierError),
301 #[error("Intent {intent_id} was published but processing is not confirmed")]
303 PublishedButUnconfirmed {
304 intent_id: i32,
305 #[source]
306 cause: Option<Box<crate::subscriptions::barrier::BarrierError>>,
307 rate_limit_delay: Option<xmtp_common::time::Duration>,
310 },
311 #[error("Missing pending commit")]
315 MissingPendingCommit,
316 #[error(transparent)]
320 ProcessIntent(#[from] ProcessIntentError),
321 #[error("Failed to load lock")]
325 LockUnavailable,
326 #[error("Exceeded max characters for this field. Must be under: {length}")]
330 TooManyCharacters { length: usize },
331 #[error(
338 "app data update was superseded; expected {expected:?} but the committed value is {actual:?}"
339 )]
340 AppDataSuperseded { expected: String, actual: String },
341 #[error("Group is paused until version {0} is available")]
345 GroupPausedUntilUpdate(String),
346 #[error("Group is inactive")]
350 GroupInactive,
351 #[error("{}", _0.to_string())]
355 Sync(#[from] Box<SyncSummary>),
356 #[error(transparent)]
360 Db(#[from] xmtp_db::ConnectionError),
361 #[error(transparent)]
365 MlsStore(#[from] MlsStoreError),
366 #[error(transparent)]
370 MetadataPermissionsError(#[from] MetadataPermissionsError),
371 #[error("Failed to verify all installations: failedInstallations={0}")]
375 FailedToVerifyInstallations(FailedInstallationIds),
376 #[error("no welcomes to send")]
380 NoWelcomesToSend,
381 #[error("Codec error: {0}")]
385 CodecError(#[from] CodecError),
386 #[error(transparent)]
390 WrapWelcome(#[from] WrapPayloadError),
391 #[error(transparent)]
395 UnwrapWelcome(#[from] UnwrapPayloadError),
396 #[error("Failed to retrieve welcome data from topic {0}")]
400 WelcomeDataNotFound(String),
401 #[error("Welcome requires group {group_id} to process through {anchor}")]
403 WelcomeGroupPrefixPending { group_id: GroupId, anchor: u64 },
404 #[error("Welcome join metadata is invalid")]
406 InvalidWelcomeMetadata,
407 #[error("Welcome group root does not name its group: {0}")]
410 WelcomeGroupRoot(xmtp_mls_group_state::app_data::group_root::GroupRootError),
411 #[error("Welcome requires client version {0}")]
413 UnsupportedWelcomeVersion(String),
414 #[error("Result was not initialized")]
418 UninitializedResult,
419 #[error(transparent)]
423 Diesel(#[from] xmtp_db::diesel::result::Error),
424 #[error(transparent)]
428 UninitializedField(#[from] derive_builder::UninitializedFieldError),
429 #[error(transparent)]
434 DeleteMessage(#[from] DeleteMessageError),
435}
436
437impl From<xmtp_mls_validation::commit::CommitRuleError> for GroupError {
438 fn from(error: xmtp_mls_validation::commit::CommitRuleError) -> Self {
439 Self::CommitValidation(error.into())
440 }
441}
442
443#[derive(Error, Debug)]
444pub enum DeleteMessageError {
445 #[error("Message not found: {0}")]
446 MessageNotFound(String),
447 #[error("Not authorized to delete this message")]
448 NotAuthorized,
449 #[error("Cannot delete this message type")]
450 NonDeletableMessage,
451 #[error("Message already deleted")]
452 MessageAlreadyDeleted,
453 #[error("Message is being published")]
456 MessageInFlight,
457}
458
459impl RetryableError for DeleteMessageError {
460 fn is_retryable(&self) -> bool {
461 matches!(self, Self::MessageInFlight)
462 }
463}
464
465impl From<prost::EncodeError> for GroupError {
466 fn from(value: prost::EncodeError) -> Self {
467 GroupError::ConversionError(value.into())
468 }
469}
470
471impl From<prost::DecodeError> for GroupError {
472 fn from(value: prost::DecodeError) -> Self {
473 GroupError::ConversionError(value.into())
474 }
475}
476
477impl From<SyncSummary> for GroupError {
478 fn from(value: SyncSummary) -> Self {
479 GroupError::Sync(Box::new(value))
480 }
481}
482
483#[derive(Error, Debug)]
484pub enum MetadataPermissionsError {
485 #[error(transparent)]
486 Permissions(#[from] GroupMutablePermissionsError),
487 #[error(transparent)]
488 Mutable(#[from] GroupMutableMetadataError),
489 #[error("Metadata error {0}")]
490 GroupMetadata(#[from] GroupMetadataError),
491 #[error("Invalid permission update")]
492 InvalidPermissionUpdate,
493 #[error("cannot change metadata of DM")]
494 DmGroupMetadataForbidden,
495 #[error(transparent)]
496 DmValidation(#[from] DmValidationError),
497 #[error("Invalid extension: {0}")]
498 InvalidExtension(#[from] openmls::prelude::InvalidExtensionError),
499 #[error(transparent)]
505 ComponentSource(#[from] xmtp_mls_group_state::app_data::component_source::ComponentSourceError),
506}
507
508impl RetryableError for MetadataPermissionsError {
509 fn is_retryable(&self) -> bool {
510 false
511 }
512}
513
514#[derive(Error, Debug)]
515pub enum GroupLeaveValidationError {
516 #[error("cannot leave a DM conversation")]
517 DmLeaveForbidden,
518 #[error("cannot leave a group that has only one member")]
519 SingleMemberLeaveRejected,
520 #[error("super-admin cannot leave a group; must be demoted first")]
521 SuperAdminLeaveForbidden,
522 #[error("inbox ID already exists in the pending leave list")]
523 InboxAlreadyInPendingList,
524 #[error("inbox ID does not exist in the pending leave list")]
525 InboxNotInPendingList,
526 #[error("only a member of the group can send a leave request or retract a leave request")]
527 NotAGroupMember,
528}
529
530impl RetryableError for GroupLeaveValidationError {
531 fn is_retryable(&self) -> bool {
532 false
533 }
534}
535
536#[derive(Error, Debug)]
537pub enum DmValidationError {
538 #[error("DM group must include our inbox in its member pair")]
539 OurInboxMustBeMember,
540 #[error("DM group must have DM_MEMBERS set")]
541 MustHaveMembersSet,
542 #[error("Invalid conversation type for DM group")]
543 InvalidConversationType,
544 #[error("DM members do not match expected inboxes")]
545 ExpectedInboxesDoNotMatch,
546 #[error("DM group must have empty admin and super admin lists")]
547 MustHaveEmptyAdminAndSuperAdmin,
548 #[error("Invalid permissions for DM group")]
549 InvalidPermissions,
550 #[error("DM group membership is invalid")]
551 InvalidMembership,
552 #[error("DM group member credential is invalid")]
553 InvalidMemberCredential,
554 #[error("DM group includes an inbox outside its pair")]
555 MemberOutsidePair,
556 #[error("stored DM identifier does not match its validated pair")]
557 StoredDmIdMismatch,
558}
559
560impl RetryableError for DmValidationError {
561 fn is_retryable(&self) -> bool {
562 match self {
563 Self::OurInboxMustBeMember
564 | Self::MustHaveMembersSet
565 | Self::InvalidConversationType
566 | Self::ExpectedInboxesDoNotMatch
567 | Self::MustHaveEmptyAdminAndSuperAdmin
568 | Self::InvalidPermissions
569 | Self::InvalidMembership
570 | Self::InvalidMemberCredential
571 | Self::MemberOutsidePair
572 | Self::StoredDmIdMismatch => false,
573 }
574 }
575}
576
577impl RetryableError for GroupError {
578 fn is_retryable(&self) -> bool {
579 match self {
580 Self::MessageSenderConflict => false,
581 Self::OutgoingPreparation(error) => error.is_retryable(),
582 Self::IdentityUpdate(error) => error.is_retryable(),
583 Self::Storage(storage) => storage.is_retryable(),
584 Self::ReceiveError(msg) => msg.is_retryable(),
585 Self::Identity(identity) => identity.is_retryable(),
586 Self::GroupCreate(group) => group.is_retryable(),
587 Self::SelfUpdate(update) => update.is_retryable(),
588 Self::WelcomeError(
589 openmls::prelude::WelcomeError::UnsupportedMlsVersion
590 | openmls::prelude::WelcomeError::UnsupportedExtensions,
591 ) => true,
592 Self::WelcomeError(welcome) => welcome.is_retryable(),
593 Self::SqlKeyStore(sql) => sql.is_retryable(),
594 Self::InstallationDiff(diff) => diff.is_retryable(),
595 Self::CreateGroupContextExtProposalError(create) => create.is_retryable(),
596 Self::ProposeAddMember(e) => e.is_retryable(),
597 Self::ProposeRemoveMember(e) => e.is_retryable(),
598 Self::Proposal(e) => e.is_retryable(),
599 Self::CommitToPendingProposals(e) => e.is_retryable(),
600 Self::ProposalsNotSupported(_) => false,
601 Self::MinVersionExceedsOwnVersion { .. } => false,
602 Self::MinVersionDowngrade { .. } => false,
603 Self::InvalidMinVersion { .. } => false,
604 Self::ComponentSource(_) => false,
605 Self::MetadataField(_) => false,
606 Self::AppDataCommit(e) => e.is_retryable(),
607 Self::BootstrapSynthesis(e) => e.is_retryable(),
608 Self::CommitValidation(err) => err.is_retryable(),
609 Self::WrappedApi(err) => err.is_retryable(),
610 Self::ProcessIntent(err) => err.is_retryable(),
611 Self::LockUnavailable => true,
612 Self::WelcomeGroupPrefixPending { .. } | Self::UnsupportedWelcomeVersion(_) => true,
613 Self::SyncFailedToWait(_) => true,
614 Self::StreamBarrier(error) => error.is_retryable(),
615 Self::PublishedButUnconfirmed { .. } => true,
616 Self::CodecError(_) => true,
617 Self::Sync(s) => s.is_retryable(),
618 Self::Db(e) => e.is_retryable(),
619 Self::MlsStore(e) => e.is_retryable(),
620 Self::MetadataPermissionsError(e) => e.is_retryable(),
621 Self::WrapWelcome(e) => e.is_retryable(),
622 Self::UnwrapWelcome(e) => e.is_retryable(),
623 Self::Diesel(e) => e.is_retryable(),
624 Self::LeaveCantProcessed(e) => e.is_retryable(),
625 Self::DeleteMessage(e) => e.is_retryable(),
626 Self::MergePendingCommit(e) => e.is_retryable(),
627 Self::MissingSequenceId => true,
634 Self::NotFound(_)
635 | Self::Blocked(_)
636 | Self::ConsentAct(_)
637 | Self::InvalidWelcomeMetadata
638 | Self::WelcomeGroupRoot(_)
639 | Self::UserLimitExceeded
640 | Self::InvalidGroupMembership
641 | Self::Intent(_)
642 | Self::CreateMessage(_)
643 | Self::TlsError(_)
644 | Self::AddressNotFound(_)
645 | Self::InvalidExtension(_)
646 | Self::Signature(_)
647 | Self::LeafNodeError(_)
648 | Self::NoPSKSupport
649 | Self::MissingPendingCommit
650 | Self::AddressValidation(_)
651 | Self::InvalidPublicKeys(_)
652 | Self::CredentialError(_)
653 | Self::ConversionError(_)
654 | Self::CryptoError(_)
655 | Self::TooManyCharacters { .. }
656 | Self::AppDataSuperseded { .. }
657 | Self::GroupPausedUntilUpdate(_)
658 | Self::GroupInactive
659 | Self::ReservedTranscriptContentType
660 | Self::FailedToVerifyInstallations(_)
661 | Self::NoWelcomesToSend
662 | Self::WelcomeDataNotFound(_)
663 | Self::UninitializedField(_)
664 | Self::UninitializedResult => false,
665 }
666 }
667
668 fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
669 match self {
670 Self::IdentityUpdate(error) => error.rate_limit_delay(),
671 Self::ReceiveError(msg) => msg.rate_limit_delay(),
672 Self::Identity(identity) => identity.rate_limit_delay(),
673 Self::InstallationDiff(diff) => diff.rate_limit_delay(),
674 Self::CommitValidation(err) => err.rate_limit_delay(),
675 Self::WrappedApi(err) => err.rate_limit_delay(),
676 Self::StreamBarrier(error) => error.rate_limit_delay(),
677 Self::SyncFailedToWait(summary) | Self::Sync(summary) => summary.rate_limit_delay(),
678 Self::PublishedButUnconfirmed {
679 cause,
680 rate_limit_delay,
681 ..
682 } => (*rate_limit_delay).max(cause.as_ref().and_then(|cause| cause.rate_limit_delay())),
683 Self::MlsStore(e) => e.rate_limit_delay(),
684 _ => None,
685 }
686 }
687}
688
689impl crate::worker::NeedsDbReconnect for GroupError {
690 #[deny(clippy::wildcard_enum_match_arm)]
693 fn needs_db_reconnect(&self) -> bool {
694 match self {
695 Self::Storage(s) => s.db_needs_connection(),
696 Self::IdentityUpdate(error) => error.needs_db_reconnect(),
697 Self::Db(c) => c.db_needs_connection(),
698 Self::MlsStore(s) => s.needs_db_reconnect(),
699 Self::Identity(i) => i.needs_db_reconnect(),
700 Self::StreamBarrier(error) => error.needs_db_reconnect(),
701 Self::PublishedButUnconfirmed { cause, .. } => cause
702 .as_ref()
703 .is_some_and(|error| error.needs_db_reconnect()),
704 Self::ReceiveError(error) => error.needs_db_reconnect(),
705 Self::Sync(summary) | Self::SyncFailedToWait(summary) => summary.needs_db_reconnect(),
706 Self::CommitValidation(error) => error.needs_db_reconnect(),
707 Self::InstallationDiff(error) => error.needs_db_reconnect(),
708 Self::ProcessIntent(ProcessIntentError::Storage(error)) => error.db_needs_connection(),
709 Self::SqlKeyStore(sql_key_store::SqlKeyStoreError::Connection(error)) => {
710 error.db_needs_connection()
711 }
712 Self::ProcessIntent(_)
713 | Self::SqlKeyStore(_)
714 | Self::ReservedTranscriptContentType
715 | Self::MessageSenderConflict
716 | Self::OutgoingPreparation(..)
717 | Self::NotFound(..)
718 | Self::UserLimitExceeded
719 | Self::MissingSequenceId
720 | Self::AddressNotFound(..)
721 | Self::WrappedApi(..)
722 | Self::InvalidGroupMembership
723 | Self::ConsentAct(..)
724 | Self::LeaveCantProcessed(..)
725 | Self::Intent(..)
726 | Self::CreateMessage(..)
727 | Self::TlsError(..)
728 | Self::GroupCreate(..)
729 | Self::SelfUpdate(..)
730 | Self::WelcomeError(..)
731 | Self::InvalidExtension(..)
732 | Self::Signature(..)
733 | Self::Blocked(..)
734 | Self::AddressValidation(..)
735 | Self::InvalidPublicKeys(..)
736 | Self::ConversionError(..)
737 | Self::CryptoError(..)
738 | Self::CreateGroupContextExtProposalError(..)
739 | Self::ProposeAddMember(..)
740 | Self::ProposeRemoveMember(..)
741 | Self::Proposal(..)
742 | Self::CommitToPendingProposals(..)
743 | Self::MergePendingCommit(..)
744 | Self::ProposalsNotSupported(..)
745 | Self::MinVersionExceedsOwnVersion { .. }
746 | Self::MinVersionDowngrade { .. }
747 | Self::InvalidMinVersion { .. }
748 | Self::ComponentSource(..)
749 | Self::MetadataField(..)
750 | Self::AppDataCommit(..)
751 | Self::BootstrapSynthesis(..)
752 | Self::CredentialError(..)
753 | Self::LeafNodeError(..)
754 | Self::NoPSKSupport
755 | Self::MissingPendingCommit
756 | Self::LockUnavailable
757 | Self::TooManyCharacters { .. }
758 | Self::AppDataSuperseded { .. }
759 | Self::GroupPausedUntilUpdate(..)
760 | Self::GroupInactive
761 | Self::MetadataPermissionsError(..)
762 | Self::FailedToVerifyInstallations(..)
763 | Self::NoWelcomesToSend
764 | Self::CodecError(..)
765 | Self::WrapWelcome(..)
766 | Self::UnwrapWelcome(..)
767 | Self::WelcomeDataNotFound(..)
768 | Self::WelcomeGroupPrefixPending { .. }
769 | Self::InvalidWelcomeMetadata
770 | Self::WelcomeGroupRoot(..)
771 | Self::UnsupportedWelcomeVersion(..)
772 | Self::UninitializedResult
773 | Self::Diesel(..)
774 | Self::UninitializedField(..)
775 | Self::DeleteMessage(..) => false,
776 }
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[xmtp_common::test]
785 fn missing_sequence_id_is_retryable() {
786 assert!(GroupError::MissingSequenceId.is_retryable());
791 }
792
793 #[xmtp_common::test]
794 fn failed_to_verify_installations_includes_hex_ids() {
795 let err = GroupError::FailedToVerifyInstallations(FailedInstallationIds(vec![
796 vec![0xAB; 32],
797 vec![0xCD; 32],
798 ]));
799 let message = err.to_string();
800 assert!(message.contains("failedInstallations="));
801 assert!(message.contains(&hex::encode([0xAB; 32])));
802 assert!(message.contains(&hex::encode([0xCD; 32])));
803 assert!(!err.is_retryable());
804 }
805}