Return current state in transient errors#1724
Conversation
Coverage Report for CI Build 29110198013Coverage increased (+0.02%) to 86.026%Details
Uncovered Changes
Coverage Regressions7 previously-covered lines in 2 files lost coverage.
Coverage Stats
💛 - Coveralls |
benalleng
left a comment
There was a problem hiding this comment.
I am ok with any of the shapes this takes and this is looking good. I'm curious what we want to change in the cli with this approach. There is a dart exception naming problem
c3c3df9 to
3e3593d
Compare
benalleng
left a comment
There was a problem hiding this comment.
CACK I would still like to see the how the usage of e.is_transient() and e.transient_state() takes its shape in the cli
|
CLI impl with e.is_transient() & e.transient_state https://github.com/DanGould/rust-payjoin/commits/dangould/1724-cli-transient-impl/
|
|
I cherry-picked Dan's commit onto this PR and made two minor modifications: added a 5 second retry delay and updated |
|
Haven't completed thorough review but offhand it looks like Dan's commit is still missing the transient error handling in rust-payjoin/payjoin-cli/src/app/v2/mod.rs Lines 1040 to 1053 in f60825c |
99f0e55 to
bff7d29
Compare
|
Updated to add retry for Note that there are remaining transient errors in the receiver checklist chain that are intentionally left out here as they come from wallet/db callbacks, and in-place retry loops wouldn't achieve anything there. We could add case-by-case handling there in follow-ups (e.g prompting for a new set of inputs, different fee preferences, etc.) |
xstoicunicornx
left a comment
There was a problem hiding this comment.
I think were still missing current event handling for one of the transient errors.
Transient state transitions consumed the typestate and returned only an error, so callers could not retry without cloning the state ahead of every call or replaying the whole session event log. Carry ownership of the current typestate inside the transient rejection, the way mpsc::SendError hands back the unsent value. This preserves typestate linearity: a transition yields the next state on success, hands the same state back on a transient error, or closes the session on a fatal error. Transient rejections persist nothing, so the returned state always matches what replay would reconstruct. RejectTransient, Rejection, ApiError, and PersistedError gain a CurrentState parameter defaulting to (). MaybeFatalTransition, MaybeTransientTransition, and MaybeTerminalSuccessTransition grow a trailing CurrentState parameter; the WithNoResults and FatalOrSuccess transitions reuse the CurrentState they already carry for the stasis arm. The new PersistedError::transient_state accessor recovers the state. process_error_response now consumes self like every other transition. It was the one borrowing transition, which left a live handle able to re-post and append a duplicate event after a successful save; consuming self extends the exactly-one-live-handle invariant to the error-reply path, with the receiver returned through the transient error like everywhere else. Sites that moved inner data into by-value calls before the transient return (identify_receiver_outputs, apply_fee_range, finalize_proposal) now clone that data up front, adding one PSBT-sized clone to their happy paths.
Poll loops and retry logic need to branch on whether a persisted error is retryable, but PersistedError collapsed the distinction that the internal ApiError variants already encode (issue payjoin#1422). Add is_transient() and is_fatal() predicates. A transient error carries the state to resume from via transient_state(); a fatal error closed the session or moved it to an error state. Storage errors are neither: the transition outcome is unknown and recovery is replaying the event log. Cover the retry loop end to end: retrying a failed transition from the state returned by transient_state() produces an event log identical to a run that never failed, and the carried state equals what replaying the event log reconstructs.
Bindings had no way to tell whether a failed transition is retryable: ReceiverPersistedError and SenderPersistedError flattened the distinction the core error model encodes (issue payjoin#1422). uniffi error enums cannot carry methods, so encode the classification in the variant structure, which surfaces as distinct exception types in each binding language. Both enums become Transient/Fatal/Storage, with the protocol error carried inside the first two. The sender's per-kind variants move into a new SenderError enum nested inside the classification, mirroring the receiver's ReceiverError, and its three From impls collapse into a macro mirroring the receiver's. The From impls pick the variant with PersistedError::is_transient. Rename the FFI builder error from BuildSenderError to SenderBuilderError, mirroring ReceiverBuilderError. Besides the symmetry, this avoids a name collision in the generated Dart: uniffi-dart flattens each error-enum variant to a top-level class named <Variant><ParentException>, so SenderError::Build produces BuildSenderException, the same class the BuildSenderError object generates once its Error suffix is rewritten to Exception.
Add transient arms to the two existing poll loops and convert the three one-shot POST flows into retry loops that recover the state with transient_state(). - get_proposed_payjoin_psbt: transient arm retries the poll in place. Drops the defensive sender.clone() that existed because the state was previously unrecoverable from errors. - long_poll_fallback: transient arm retries the poll in place. - send_payjoin_proposal: becomes a retry loop; re-POSTing the finalized proposal is idempotent from both Bitcoin and directory perspectives. - post_original_proposal: becomes a retry loop; re-POSTing the Original PSBT is idempotent from the directory's perspective. - handle_error: branches on is_transient() and retries the error reply, resolving the ambiguity between Transient and FatalTerminate outcomes (issue payjoin#1709). Transient retries are paced by a fixed 5 second delay so a misbehaving directory or relay is not hammered in a tight loop. No delay is needed on the happy path because the directory holds long polls open, and no retry cap is needed because the request constructors fail once the session expires, exiting each loop through the existing fatal arms. The receiver's proposal-checking chain intentionally keeps propagating transient errors instead of retrying in place: those come from wallet and database callbacks whose recovery path is resuming the session, and apply_fee_range's transient error is deterministic for a given proposal, so an in-place retry loop would spin until session expiry. Co-authored-by: spacebear <git@spacebear.dev>
bff7d29 to
a7c000e
Compare
xstoicunicornx
left a comment
There was a problem hiding this comment.
ACK a7c000e
Overall looks good. State machine feels much more consistent overall.
A few suggestions, 2 of which touch API for payjoin crate. PRs for the API updates incoming.
| } | ||
| } | ||
|
|
||
| pub fn error_state(self) -> Option<ErrorState> { |
There was a problem hiding this comment.
May want rename to fatal_state for consistency/clarity?
| /// search for the transaction in the network. Since a non-SegWit input signature is going to | ||
| /// change the TXID of the Payjoin proposal, it cannot be monitored. | ||
| pub fn check_for_transaction( | ||
| &self, |
There was a problem hiding this comment.
May want to also consume self here for consistency?
| let sender = sender.process_response(&response.bytes().await?, ctx).save(persister)?; | ||
| println!("Posted Original PSBT..."); | ||
| self.get_proposed_payjoin_psbt(sender, persister).await | ||
| loop { |
There was a problem hiding this comment.
Rather than introducing a loop couldn't we just call process_receiver_session with the returned transient current state?
| #[uniffi::export(Debug, Display, Eq)] | ||
| #[error("Error initializing the sender: {msg}")] | ||
| pub struct BuildSenderError { | ||
| pub struct SenderBuilderError { |
There was a problem hiding this comment.
Renaming this to align with the ReceiverBuilderError from ffi crate makes it not follow the naming convention from PDK base crate. Why not rename ReceiverBuilderError from ffi crate to be BuildReceiverError instead to bring them both into alignment?
Closes #1422. Closes #1709.
Problem
Transient state transitions consume the typestate and return only an error. An implementer who hits a transient failure (a directory 5xx, a wallet RPC hiccup) has no way to resume: they must either defensively clone the state before every transition call or replay the whole session event log. On top of that,
PersistedErrorcollapsed the transient/fatal distinction that the internalApiErrorvariants already encode, so poll loops could not even tell whether retrying was appropriate.Approach
Transient rejections now return ownership of the current typestate inside the error, the way
mpsc::SendErrorhands back the unsent value. This preserves typestate linearity: at every point exactly one live handle exists, eitherOk(next_state), the current state inside the transientErr(via the newPersistedError::transient_state()), or none on a fatal error that closed the session. Transient rejections persist nothing, so the carried state is always identical to what replaying the event log would reconstruct, and a retried run leaves an event log indistinguishable from one that never failed. Both properties are covered by new tests.RejectTransient,Rejection,ApiError, andPersistedErrorgain aCurrentStatetype parameter defaulting to().MaybeFatalTransition,MaybeTransientTransition, andMaybeTerminalSuccessTransitiongain a trailingCurrentStateparameter; theWithNoResultsandFatalOrSuccesstransitions reuse theCurrentStatethey already carry for the stasis arm. Every transient constructor now carries the state, uniformly.process_error_responsenow consumesselflike every other transition. It was the one borrowing transition, which left a live handle able to re-post and append a duplicate event after a successful save; consumingselfextends the exactly-one-live-handle invariant to the error-reply path.PersistedErrorgainsis_transient()/is_fatal()predicates (Be specific about error handling for methods that don't return state transitions #1422). Storage errors are neither: the transition outcome is unknown and recovery is replay.ReceiverPersistedErrorandSenderPersistedErrorbecomeTransient/Fatal/Storage, surfacing as distinct exception types in each binding language. The sender's per-kind variants move into a newSenderErrorenum mirroringReceiverError. This breaks binding consumers matching the old variant names, which seems acceptable pre-1.0.BuildSenderErrortoSenderBuilderError, mirroringReceiverBuilderError. Besides the symmetry, this avoids a class-name collision in the generated Dart, where uniffi-dart flattens theSenderError::Buildvariant and theBuildSenderErrorobject to the same top-levelBuildSenderExceptionclass.apply_fee_range's transient error is deterministic for a given proposal, so an in-place retry loop would spin until session expiry.Trade-off worth reviewer attention
Three receiver transitions (
identify_receiver_outputs,apply_fee_range,finalize_proposal) moved inner data into by-value calls before the transient return, so keepingselfintact costs one PSBT-sized clone per call on the happy path. The alternative was changing inner by-value signatures shared with the v1 receiver, which seemed worse.Since this change is breaking (new type parameters), it must land before 1.0; the
#[non_exhaustive]escape hatch would not have covered it later.Disclosure: co-authored by Claude Code
🤖 Generated with Claude Code