Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions payjoin-ffi/python/test/test_payjoin_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,5 +230,101 @@ def test_sender_builder_rejects_bad_psbt(self):
payjoin.SenderBuilder("not-a-psbt", uri)


class TestRetryMetadata(unittest.TestCase):
@staticmethod
def _ohttp_keys():
return payjoin.OhttpKeys.decode(
bytes.fromhex(
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"
)
)

def _receiver_builder(self, expiration=None):
builder = payjoin.ReceiverBuilder(
"2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK",
"https://example.com",
self._ohttp_keys(),
)
if expiration is not None:
builder = builder.with_expiration(expiration)
return builder

def test_sender_create_request_exposes_expiration_metadata(self):
recv_persister = InMemoryReceiverPersister(10)
receiver = self._receiver_builder(expiration=0).build().save(recv_persister)
uri = receiver.pj_uri()
sender = (
payjoin.SenderBuilder(payjoin.original_psbt(), uri)
.build_recommended(1000)
.save(InMemorySenderPersister(11))
)

with self.assertRaises(payjoin.CreateRequestError) as ctx:
sender.create_v2_post_request("https://example.com")

self.assertFalse(ctx.exception.is_retryable())
self.assertIsInstance(ctx.exception.expired_at_unix_seconds(), int)

def test_receiver_error_exposes_expiration_metadata(self):
receiver = (
self._receiver_builder(expiration=0)
.build()
.save(InMemoryReceiverPersister(12))
)

with self.assertRaises(payjoin.ReceiverError.Protocol) as ctx:
receiver.create_poll_request("https://example.com")

protocol_error = ctx.exception[0]
self.assertFalse(protocol_error.is_retryable())
self.assertIsInstance(protocol_error.expired_at_unix_seconds(), int)

def test_sender_persisted_error_keeps_retryable_transport_signal(self):
recv_persister = InMemoryReceiverPersister(13)
receiver = self._receiver_builder().build().save(recv_persister)
uri = receiver.pj_uri()
sender = (
payjoin.SenderBuilder(payjoin.original_psbt(), uri)
.build_recommended(1000)
.save(InMemorySenderPersister(14))
)
request = sender.create_v2_post_request("https://example.com")
transition = sender.process_response(b"", request.ohttp_ctx)

with self.assertRaises(payjoin.SenderPersistedError.EncapsulationError) as ctx:
transition.save(InMemorySenderPersister(15))

self.assertTrue(ctx.exception[0].is_retryable())

def test_replay_errors_expose_expiration_metadata(self):
recv_persister = InMemoryReceiverPersister(16)
self._receiver_builder(expiration=0).build().save(recv_persister)

with self.assertRaises(payjoin.ReceiverReplayError) as recv_ctx:
payjoin.replay_receiver_event_log(recv_persister)

self.assertFalse(recv_ctx.exception.is_retryable())
self.assertIsInstance(recv_ctx.exception.expired_at_unix_seconds(), int)

sender_persister = InMemorySenderPersister(17)
receiver = (
self._receiver_builder(expiration=0)
.build()
.save(InMemoryReceiverPersister(18))
)
uri = receiver.pj_uri()
(
payjoin.SenderBuilder(payjoin.original_psbt(), uri)
.build_recommended(1000)
.save(sender_persister)
)

with self.assertRaises(payjoin.SenderReplayError) as send_ctx:
payjoin.replay_sender_event_log(sender_persister)

self.assertFalse(send_ctx.exception.is_retryable())
self.assertIsInstance(send_ctx.exception.expired_at_unix_seconds(), int)


if __name__ == "__main__":
unittest.main()
5 changes: 5 additions & 0 deletions payjoin-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ impl From<ImplementationError> for payjoin::ImplementationError {
fn from(value: ImplementationError) -> Self { value.0 }
}

#[uniffi::export]
impl ImplementationError {
pub fn is_retryable(&self) -> bool { true }
}

#[derive(Debug, thiserror::Error, uniffi::Object)]
#[error("Error de/serializing JSON object: {0}")]
pub struct SerdeJsonError(#[from] serde_json::Error);
Expand Down
21 changes: 21 additions & 0 deletions payjoin-ffi/src/receive/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ impl From<payjoin::bitcoin::address::ParseError> for AddressParseError {
#[error(transparent)]
pub struct ProtocolError(#[from] receive::ProtocolError);

#[uniffi::export]
impl ProtocolError {
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }

pub fn expired_at_unix_seconds(&self) -> Option<u32> { self.0.expired_at_unix_seconds() }
}

/// The standard format for errors that can be replied as JSON.
///
/// The JSON output includes the following fields:
Expand Down Expand Up @@ -168,6 +175,13 @@ impl From<ProtocolError> for JsonReply {
#[error(transparent)]
pub struct SessionError(#[from] receive::v2::SessionError);

#[uniffi::export]
impl SessionError {
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }

pub fn expired_at_unix_seconds(&self) -> Option<u32> { self.0.expired_at_unix_seconds() }
}

/// Protocol error raised during output substitution.
#[derive(Debug, thiserror::Error, uniffi::Object)]
#[error(transparent)]
Expand Down Expand Up @@ -237,3 +251,10 @@ impl From<FfiValidationError> for InputPairError {
pub struct ReceiverReplayError(
#[from] payjoin::error::ReplayError<receive::v2::ReceiveSession, receive::v2::SessionEvent>,
);

#[uniffi::export]
impl ReceiverReplayError {
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }

pub fn expired_at_unix_seconds(&self) -> Option<u32> { self.0.expired_at_unix_seconds() }
}
34 changes: 34 additions & 0 deletions payjoin-ffi/src/send/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ impl From<send::BuildSenderError> for BuildSenderError {
fn from(value: send::BuildSenderError) -> Self { BuildSenderError { msg: value.to_string() } }
}

#[uniffi::export]
impl BuildSenderError {
pub fn is_retryable(&self) -> bool { false }
}

/// FFI-visible PSBT parsing error surfaced at the sender boundary.
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum PsbtParseError {
Expand Down Expand Up @@ -58,16 +63,33 @@ impl From<FfiValidationError> for SenderInputError {
#[error(transparent)]
pub struct CreateRequestError(#[from] send::v2::CreateRequestError);

#[uniffi::export]
impl CreateRequestError {
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }

pub fn expired_at_unix_seconds(&self) -> Option<u32> { self.0.expired_at_unix_seconds() }
}

/// Error returned for v2-specific payload encapsulation errors.
#[derive(Debug, thiserror::Error, uniffi::Object)]
#[error(transparent)]
pub struct EncapsulationError(#[from] send::v2::EncapsulationError);

#[uniffi::export]
impl EncapsulationError {
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }
}

/// Error that may occur when the response from receiver is malformed.
#[derive(Debug, thiserror::Error, uniffi::Object)]
#[error(transparent)]
pub struct ValidationError(#[from] send::ValidationError);

#[uniffi::export]
impl ValidationError {
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }
}

/// Represent an error returned by Payjoin receiver.
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum ResponseError {
Expand Down Expand Up @@ -109,13 +131,25 @@ impl From<send::ResponseError> for ResponseError {
#[error(transparent)]
pub struct WellKnownError(#[from] send::WellKnownError);

#[uniffi::export]
impl WellKnownError {
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }
}

/// Error that may occur when the sender session event log is replayed
#[derive(Debug, thiserror::Error, uniffi::Object)]
#[error(transparent)]
pub struct SenderReplayError(
#[from] payjoin::error::ReplayError<send::v2::SendSession, send::v2::SessionEvent>,
);

#[uniffi::export]
impl SenderReplayError {
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }

pub fn expired_at_unix_seconds(&self) -> Option<u32> { self.0.expired_at_unix_seconds() }
}

/// Error that may occur during state machine transitions
#[derive(Debug, thiserror::Error, uniffi::Error)]
#[error(transparent)]
Expand Down
5 changes: 5 additions & 0 deletions payjoin-ffi/src/uri/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ pub struct UrlParseError(#[from] url::ParseError);
#[error(transparent)]
pub struct IntoUrlError(#[from] payjoin::IntoUrlError);

#[uniffi::export]
impl IntoUrlError {
pub fn is_retryable(&self) -> bool { false }
}

#[derive(Debug, thiserror::Error, uniffi::Object)]
#[error("{msg}")]
pub struct FeeRateError {
Expand Down
35 changes: 35 additions & 0 deletions payjoin/src/core/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ impl<SessionState: Debug, SessionEvent: Debug> From<InternalReplayError<SessionS
fn from(e: InternalReplayError<SessionState, SessionEvent>) -> Self { ReplayError(e) }
}

#[cfg(feature = "v2")]
impl<SessionState: Debug, SessionEvent: Debug> ReplayError<SessionState, SessionEvent> {
pub fn is_retryable(&self) -> bool {
matches!(self.0, InternalReplayError::PersistenceFailure(_))
}

pub fn expired_at_unix_seconds(&self) -> Option<u32> {
match &self.0 {
InternalReplayError::Expired(expiration) => Some(expiration.to_unix_seconds()),
_ => None,
}
}
}

#[cfg(feature = "v2")]
#[derive(Debug)]
pub(crate) enum InternalReplayError<SessionState, SessionEvent> {
Expand All @@ -81,3 +95,24 @@ pub(crate) enum InternalReplayError<SessionState, SessionEvent> {
/// Application storage error
PersistenceFailure(ImplementationError),
}

#[cfg(all(test, feature = "v2"))]
mod tests {
use std::time::{Duration, SystemTime};

use super::*;

#[test]
fn test_replay_error_retryability_and_expiration_metadata() {
let expiration =
crate::time::Time::try_from(SystemTime::now() - Duration::from_secs(1)).unwrap();
let expired: ReplayError<(), ()> = InternalReplayError::Expired(expiration).into();
assert!(!expired.is_retryable());
assert_eq!(expired.expired_at_unix_seconds(), Some(expiration.to_unix_seconds()));

let persistence_failure: ReplayError<(), ()> =
InternalReplayError::PersistenceFailure(ImplementationError::from("storage")).into();
assert!(persistence_failure.is_retryable());
assert_eq!(persistence_failure.expired_at_unix_seconds(), None);
}
}
2 changes: 2 additions & 0 deletions payjoin/src/core/ohttp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ impl DirectoryResponseError {
UnexpectedStatusCode(status_code) => status_code.is_client_error(),
}
}

pub(crate) fn is_retryable(&self) -> bool { !self.is_fatal() }
}

impl fmt::Display for DirectoryResponseError {
Expand Down
42 changes: 42 additions & 0 deletions payjoin/src/core/receive/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,26 @@ impl error::Error for ProtocolError {
}
}

impl ProtocolError {
pub fn is_retryable(&self) -> bool {
match self {
Self::OriginalPayload(_) => false,
#[cfg(feature = "v1")]
Self::V1(_) => false,
#[cfg(feature = "v2")]
Self::V2(error) => error.is_retryable(),
}
}

pub fn expired_at_unix_seconds(&self) -> Option<u32> {
match self {
#[cfg(feature = "v2")]
Self::V2(error) => error.expired_at_unix_seconds(),
_ => None,
}
}
}

impl From<InternalPayloadError> for Error {
fn from(e: InternalPayloadError) -> Self {
Error::Protocol(ProtocolError::OriginalPayload(e.into()))
Expand Down Expand Up @@ -433,6 +453,8 @@ impl From<InternalInputContributionError> for InputContributionError {

#[cfg(test)]
mod tests {
use std::time::{Duration, SystemTime};

use super::*;
use crate::ImplementationError;

Expand Down Expand Up @@ -504,4 +526,24 @@ mod tests {
assert_eq!(json["errorCode"], "original-psbt-rejected");
assert_eq!(json["message"], "Missing payment.");
}

#[cfg(feature = "v2")]
#[test]
fn test_protocol_error_exposes_retryability_and_expiration() {
let expiration =
crate::time::Time::try_from(SystemTime::now() - Duration::from_secs(1)).unwrap();
let expired = ProtocolError::V2(crate::receive::v2::SessionError::from(
crate::receive::v2::InternalSessionError::Expired(expiration),
));
assert!(!expired.is_retryable());
assert_eq!(expired.expired_at_unix_seconds(), Some(expiration.to_unix_seconds()));

let retryable = ProtocolError::V2(crate::receive::v2::SessionError::from(
crate::receive::v2::InternalSessionError::DirectoryResponse(
crate::ohttp::DirectoryResponseError::InvalidSize(1),
),
));
assert!(retryable.is_retryable());
assert_eq!(retryable.expired_at_unix_seconds(), None);
}
}
40 changes: 40 additions & 0 deletions payjoin/src/core/receive/v2/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,43 @@ impl error::Error for SessionError {
}
}
}

impl SessionError {
pub fn is_retryable(&self) -> bool {
match &self.0 {
InternalSessionError::ParseUrl(_)
| InternalSessionError::Expired(_)
| InternalSessionError::OhttpEncapsulation(_)
| InternalSessionError::Hpke(_) => false,
InternalSessionError::DirectoryResponse(error) => error.is_retryable(),
}
}

pub fn expired_at_unix_seconds(&self) -> Option<u32> {
match &self.0 {
InternalSessionError::Expired(expiration) => Some(expiration.to_unix_seconds()),
_ => None,
}
}
}

#[cfg(test)]
mod tests {
use std::time::{Duration, SystemTime};

use super::*;

#[test]
fn test_session_error_exposes_retryability_and_expiration() {
let expiration =
Time::try_from(SystemTime::now() - Duration::from_secs(1)).expect("valid timestamp");
let expired: SessionError = InternalSessionError::Expired(expiration).into();
assert!(!expired.is_retryable());
assert_eq!(expired.expired_at_unix_seconds(), Some(expiration.to_unix_seconds()));

let retryable: SessionError =
InternalSessionError::DirectoryResponse(DirectoryResponseError::InvalidSize(1)).into();
assert!(retryable.is_retryable());
assert_eq!(retryable.expired_at_unix_seconds(), None);
}
}
Loading
Loading