Skip to content

Commit 8e36a85

Browse files
committed
Expose Retryability Metadata
Add retryability and expiration accessors to the core v2 session, transport, replay, and response errors, then export the same metadata through the existing UniFFI object wrappers. This lets bindings distinguish expired sessions from transient directory failures without parsing display strings or collapsing everything into opaque transport errors.
1 parent 332755b commit 8e36a85

12 files changed

Lines changed: 398 additions & 0 deletions

File tree

payjoin-ffi/python/test/test_payjoin_unit_test.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,5 +230,101 @@ def test_sender_builder_rejects_bad_psbt(self):
230230
payjoin.SenderBuilder("not-a-psbt", uri)
231231

232232

233+
class TestRetryMetadata(unittest.TestCase):
234+
@staticmethod
235+
def _ohttp_keys():
236+
return payjoin.OhttpKeys.decode(
237+
bytes.fromhex(
238+
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"
239+
)
240+
)
241+
242+
def _receiver_builder(self, expiration=None):
243+
builder = payjoin.ReceiverBuilder(
244+
"2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK",
245+
"https://example.com",
246+
self._ohttp_keys(),
247+
)
248+
if expiration is not None:
249+
builder = builder.with_expiration(expiration)
250+
return builder
251+
252+
def test_sender_create_request_exposes_expiration_metadata(self):
253+
recv_persister = InMemoryReceiverPersister(10)
254+
receiver = self._receiver_builder(expiration=0).build().save(recv_persister)
255+
uri = receiver.pj_uri()
256+
sender = (
257+
payjoin.SenderBuilder(payjoin.original_psbt(), uri)
258+
.build_recommended(1000)
259+
.save(InMemorySenderPersister(11))
260+
)
261+
262+
with self.assertRaises(payjoin.CreateRequestError) as ctx:
263+
sender.create_v2_post_request("https://example.com")
264+
265+
self.assertFalse(ctx.exception.is_retryable())
266+
self.assertIsInstance(ctx.exception.expired_at_unix_seconds(), int)
267+
268+
def test_receiver_error_exposes_expiration_metadata(self):
269+
receiver = (
270+
self._receiver_builder(expiration=0)
271+
.build()
272+
.save(InMemoryReceiverPersister(12))
273+
)
274+
275+
with self.assertRaises(payjoin.ReceiverError.Protocol) as ctx:
276+
receiver.create_poll_request("https://example.com")
277+
278+
protocol_error = ctx.exception[0]
279+
self.assertFalse(protocol_error.is_retryable())
280+
self.assertIsInstance(protocol_error.expired_at_unix_seconds(), int)
281+
282+
def test_sender_persisted_error_keeps_retryable_transport_signal(self):
283+
recv_persister = InMemoryReceiverPersister(13)
284+
receiver = self._receiver_builder().build().save(recv_persister)
285+
uri = receiver.pj_uri()
286+
sender = (
287+
payjoin.SenderBuilder(payjoin.original_psbt(), uri)
288+
.build_recommended(1000)
289+
.save(InMemorySenderPersister(14))
290+
)
291+
request = sender.create_v2_post_request("https://example.com")
292+
transition = sender.process_response(b"", request.ohttp_ctx)
293+
294+
with self.assertRaises(payjoin.SenderPersistedError.EncapsulationError) as ctx:
295+
transition.save(InMemorySenderPersister(15))
296+
297+
self.assertTrue(ctx.exception[0].is_retryable())
298+
299+
def test_replay_errors_expose_expiration_metadata(self):
300+
recv_persister = InMemoryReceiverPersister(16)
301+
self._receiver_builder(expiration=0).build().save(recv_persister)
302+
303+
with self.assertRaises(payjoin.ReceiverReplayError) as recv_ctx:
304+
payjoin.replay_receiver_event_log(recv_persister)
305+
306+
self.assertFalse(recv_ctx.exception.is_retryable())
307+
self.assertIsInstance(recv_ctx.exception.expired_at_unix_seconds(), int)
308+
309+
sender_persister = InMemorySenderPersister(17)
310+
receiver = (
311+
self._receiver_builder(expiration=0)
312+
.build()
313+
.save(InMemoryReceiverPersister(18))
314+
)
315+
uri = receiver.pj_uri()
316+
(
317+
payjoin.SenderBuilder(payjoin.original_psbt(), uri)
318+
.build_recommended(1000)
319+
.save(sender_persister)
320+
)
321+
322+
with self.assertRaises(payjoin.SenderReplayError) as send_ctx:
323+
payjoin.replay_sender_event_log(sender_persister)
324+
325+
self.assertFalse(send_ctx.exception.is_retryable())
326+
self.assertIsInstance(send_ctx.exception.expired_at_unix_seconds(), int)
327+
328+
233329
if __name__ == "__main__":
234330
unittest.main()

payjoin-ffi/src/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ impl From<ImplementationError> for payjoin::ImplementationError {
1717
fn from(value: ImplementationError) -> Self { value.0 }
1818
}
1919

20+
#[uniffi::export]
21+
impl ImplementationError {
22+
pub fn is_retryable(&self) -> bool { true }
23+
}
24+
2025
#[derive(Debug, thiserror::Error, uniffi::Object)]
2126
#[error("Error de/serializing JSON object: {0}")]
2227
pub struct SerdeJsonError(#[from] serde_json::Error);

payjoin-ffi/src/receive/error.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ impl From<payjoin::bitcoin::address::ParseError> for AddressParseError {
139139
#[error(transparent)]
140140
pub struct ProtocolError(#[from] receive::ProtocolError);
141141

142+
#[uniffi::export]
143+
impl ProtocolError {
144+
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }
145+
146+
pub fn expired_at_unix_seconds(&self) -> Option<u32> { self.0.expired_at_unix_seconds() }
147+
}
148+
142149
/// The standard format for errors that can be replied as JSON.
143150
///
144151
/// The JSON output includes the following fields:
@@ -168,6 +175,13 @@ impl From<ProtocolError> for JsonReply {
168175
#[error(transparent)]
169176
pub struct SessionError(#[from] receive::v2::SessionError);
170177

178+
#[uniffi::export]
179+
impl SessionError {
180+
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }
181+
182+
pub fn expired_at_unix_seconds(&self) -> Option<u32> { self.0.expired_at_unix_seconds() }
183+
}
184+
171185
/// Protocol error raised during output substitution.
172186
#[derive(Debug, thiserror::Error, uniffi::Object)]
173187
#[error(transparent)]
@@ -237,3 +251,10 @@ impl From<FfiValidationError> for InputPairError {
237251
pub struct ReceiverReplayError(
238252
#[from] payjoin::error::ReplayError<receive::v2::ReceiveSession, receive::v2::SessionEvent>,
239253
);
254+
255+
#[uniffi::export]
256+
impl ReceiverReplayError {
257+
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }
258+
259+
pub fn expired_at_unix_seconds(&self) -> Option<u32> { self.0.expired_at_unix_seconds() }
260+
}

payjoin-ffi/src/send/error.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ impl From<send::BuildSenderError> for BuildSenderError {
2222
fn from(value: send::BuildSenderError) -> Self { BuildSenderError { msg: value.to_string() } }
2323
}
2424

25+
#[uniffi::export]
26+
impl BuildSenderError {
27+
pub fn is_retryable(&self) -> bool { false }
28+
}
29+
2530
/// FFI-visible PSBT parsing error surfaced at the sender boundary.
2631
#[derive(Debug, thiserror::Error, uniffi::Error)]
2732
pub enum PsbtParseError {
@@ -58,16 +63,33 @@ impl From<FfiValidationError> for SenderInputError {
5863
#[error(transparent)]
5964
pub struct CreateRequestError(#[from] send::v2::CreateRequestError);
6065

66+
#[uniffi::export]
67+
impl CreateRequestError {
68+
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }
69+
70+
pub fn expired_at_unix_seconds(&self) -> Option<u32> { self.0.expired_at_unix_seconds() }
71+
}
72+
6173
/// Error returned for v2-specific payload encapsulation errors.
6274
#[derive(Debug, thiserror::Error, uniffi::Object)]
6375
#[error(transparent)]
6476
pub struct EncapsulationError(#[from] send::v2::EncapsulationError);
6577

78+
#[uniffi::export]
79+
impl EncapsulationError {
80+
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }
81+
}
82+
6683
/// Error that may occur when the response from receiver is malformed.
6784
#[derive(Debug, thiserror::Error, uniffi::Object)]
6885
#[error(transparent)]
6986
pub struct ValidationError(#[from] send::ValidationError);
7087

88+
#[uniffi::export]
89+
impl ValidationError {
90+
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }
91+
}
92+
7193
/// Represent an error returned by Payjoin receiver.
7294
#[derive(Debug, thiserror::Error, uniffi::Error)]
7395
pub enum ResponseError {
@@ -109,13 +131,25 @@ impl From<send::ResponseError> for ResponseError {
109131
#[error(transparent)]
110132
pub struct WellKnownError(#[from] send::WellKnownError);
111133

134+
#[uniffi::export]
135+
impl WellKnownError {
136+
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }
137+
}
138+
112139
/// Error that may occur when the sender session event log is replayed
113140
#[derive(Debug, thiserror::Error, uniffi::Object)]
114141
#[error(transparent)]
115142
pub struct SenderReplayError(
116143
#[from] payjoin::error::ReplayError<send::v2::SendSession, send::v2::SessionEvent>,
117144
);
118145

146+
#[uniffi::export]
147+
impl SenderReplayError {
148+
pub fn is_retryable(&self) -> bool { self.0.is_retryable() }
149+
150+
pub fn expired_at_unix_seconds(&self) -> Option<u32> { self.0.expired_at_unix_seconds() }
151+
}
152+
119153
/// Error that may occur during state machine transitions
120154
#[derive(Debug, thiserror::Error, uniffi::Error)]
121155
#[error(transparent)]

payjoin-ffi/src/uri/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ pub struct UrlParseError(#[from] url::ParseError);
2828
#[error(transparent)]
2929
pub struct IntoUrlError(#[from] payjoin::IntoUrlError);
3030

31+
#[uniffi::export]
32+
impl IntoUrlError {
33+
pub fn is_retryable(&self) -> bool { false }
34+
}
35+
3136
#[derive(Debug, thiserror::Error, uniffi::Object)]
3237
#[error("{msg}")]
3338
pub struct FeeRateError {

payjoin/src/core/error.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,20 @@ impl<SessionState: Debug, SessionEvent: Debug> From<InternalReplayError<SessionS
6969
fn from(e: InternalReplayError<SessionState, SessionEvent>) -> Self { ReplayError(e) }
7070
}
7171

72+
#[cfg(feature = "v2")]
73+
impl<SessionState: Debug, SessionEvent: Debug> ReplayError<SessionState, SessionEvent> {
74+
pub fn is_retryable(&self) -> bool {
75+
matches!(self.0, InternalReplayError::PersistenceFailure(_))
76+
}
77+
78+
pub fn expired_at_unix_seconds(&self) -> Option<u32> {
79+
match &self.0 {
80+
InternalReplayError::Expired(expiration) => Some(expiration.to_unix_seconds()),
81+
_ => None,
82+
}
83+
}
84+
}
85+
7286
#[cfg(feature = "v2")]
7387
#[derive(Debug)]
7488
pub(crate) enum InternalReplayError<SessionState, SessionEvent> {
@@ -81,3 +95,24 @@ pub(crate) enum InternalReplayError<SessionState, SessionEvent> {
8195
/// Application storage error
8296
PersistenceFailure(ImplementationError),
8397
}
98+
99+
#[cfg(all(test, feature = "v2"))]
100+
mod tests {
101+
use std::time::{Duration, SystemTime};
102+
103+
use super::*;
104+
105+
#[test]
106+
fn test_replay_error_retryability_and_expiration_metadata() {
107+
let expiration =
108+
crate::time::Time::try_from(SystemTime::now() - Duration::from_secs(1)).unwrap();
109+
let expired: ReplayError<(), ()> = InternalReplayError::Expired(expiration).into();
110+
assert!(!expired.is_retryable());
111+
assert_eq!(expired.expired_at_unix_seconds(), Some(expiration.to_unix_seconds()));
112+
113+
let persistence_failure: ReplayError<(), ()> =
114+
InternalReplayError::PersistenceFailure(ImplementationError::from("storage")).into();
115+
assert!(persistence_failure.is_retryable());
116+
assert_eq!(persistence_failure.expired_at_unix_seconds(), None);
117+
}
118+
}

payjoin/src/core/ohttp.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ impl DirectoryResponseError {
7070
UnexpectedStatusCode(status_code) => status_code.is_client_error(),
7171
}
7272
}
73+
74+
pub(crate) fn is_retryable(&self) -> bool { !self.is_fatal() }
7375
}
7476

7577
impl fmt::Display for DirectoryResponseError {

payjoin/src/core/receive/error.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,26 @@ impl error::Error for ProtocolError {
158158
}
159159
}
160160

161+
impl ProtocolError {
162+
pub fn is_retryable(&self) -> bool {
163+
match self {
164+
Self::OriginalPayload(_) => false,
165+
#[cfg(feature = "v1")]
166+
Self::V1(_) => false,
167+
#[cfg(feature = "v2")]
168+
Self::V2(error) => error.is_retryable(),
169+
}
170+
}
171+
172+
pub fn expired_at_unix_seconds(&self) -> Option<u32> {
173+
match self {
174+
#[cfg(feature = "v2")]
175+
Self::V2(error) => error.expired_at_unix_seconds(),
176+
_ => None,
177+
}
178+
}
179+
}
180+
161181
impl From<InternalPayloadError> for Error {
162182
fn from(e: InternalPayloadError) -> Self {
163183
Error::Protocol(ProtocolError::OriginalPayload(e.into()))
@@ -433,6 +453,8 @@ impl From<InternalInputContributionError> for InputContributionError {
433453

434454
#[cfg(test)]
435455
mod tests {
456+
use std::time::{Duration, SystemTime};
457+
436458
use super::*;
437459
use crate::ImplementationError;
438460

@@ -504,4 +526,24 @@ mod tests {
504526
assert_eq!(json["errorCode"], "original-psbt-rejected");
505527
assert_eq!(json["message"], "Missing payment.");
506528
}
529+
530+
#[cfg(feature = "v2")]
531+
#[test]
532+
fn test_protocol_error_exposes_retryability_and_expiration() {
533+
let expiration =
534+
crate::time::Time::try_from(SystemTime::now() - Duration::from_secs(1)).unwrap();
535+
let expired = ProtocolError::V2(crate::receive::v2::SessionError::from(
536+
crate::receive::v2::InternalSessionError::Expired(expiration),
537+
));
538+
assert!(!expired.is_retryable());
539+
assert_eq!(expired.expired_at_unix_seconds(), Some(expiration.to_unix_seconds()));
540+
541+
let retryable = ProtocolError::V2(crate::receive::v2::SessionError::from(
542+
crate::receive::v2::InternalSessionError::DirectoryResponse(
543+
crate::ohttp::DirectoryResponseError::InvalidSize(1),
544+
),
545+
));
546+
assert!(retryable.is_retryable());
547+
assert_eq!(retryable.expired_at_unix_seconds(), None);
548+
}
507549
}

payjoin/src/core/receive/v2/error.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,43 @@ impl error::Error for SessionError {
7373
}
7474
}
7575
}
76+
77+
impl SessionError {
78+
pub fn is_retryable(&self) -> bool {
79+
match &self.0 {
80+
InternalSessionError::ParseUrl(_)
81+
| InternalSessionError::Expired(_)
82+
| InternalSessionError::OhttpEncapsulation(_)
83+
| InternalSessionError::Hpke(_) => false,
84+
InternalSessionError::DirectoryResponse(error) => error.is_retryable(),
85+
}
86+
}
87+
88+
pub fn expired_at_unix_seconds(&self) -> Option<u32> {
89+
match &self.0 {
90+
InternalSessionError::Expired(expiration) => Some(expiration.to_unix_seconds()),
91+
_ => None,
92+
}
93+
}
94+
}
95+
96+
#[cfg(test)]
97+
mod tests {
98+
use std::time::{Duration, SystemTime};
99+
100+
use super::*;
101+
102+
#[test]
103+
fn test_session_error_exposes_retryability_and_expiration() {
104+
let expiration =
105+
Time::try_from(SystemTime::now() - Duration::from_secs(1)).expect("valid timestamp");
106+
let expired: SessionError = InternalSessionError::Expired(expiration).into();
107+
assert!(!expired.is_retryable());
108+
assert_eq!(expired.expired_at_unix_seconds(), Some(expiration.to_unix_seconds()));
109+
110+
let retryable: SessionError =
111+
InternalSessionError::DirectoryResponse(DirectoryResponseError::InvalidSize(1)).into();
112+
assert!(retryable.is_retryable());
113+
assert_eq!(retryable.expired_at_unix_seconds(), None);
114+
}
115+
}

0 commit comments

Comments
 (0)