Skip to content

Expose transient vs fatal on persisted errors#1716

Closed
chavic wants to merge 1 commit into
payjoin:masterfrom
chavic:chavic/persisted-error-kind
Closed

Expose transient vs fatal on persisted errors#1716
chavic wants to merge 1 commit into
payjoin:masterfrom
chavic:chavic/persisted-error-kind

Conversation

@chavic

@chavic chavic commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Addresses the question in #1709.

PersistedError already knows whether the underlying ApiError was Transient, Fatal, or FatalWithState, but api_error() collapses all three, so after persisting a transition the caller cannot tell whether to retry from the same state or treat the session as closed. This adds is_transient() and is_fatal() accessors (both false for storage errors, which have their own accessor).

The same information is then carried across the FFI, where today it is erased a second time: ReceiverPersistedError's single Receiver variant becomes Transient and Fatal variants. uniffi error enums can't export methods, so the kind has to ride as variants — this is a breaking change for bindings that match on Receiver, which seemed acceptable at rc stage given the alternative is that downstream languages stay stuck even after the core API gains a signal. Unclassified errors map to Fatal, the conservative default for a session-driving loop.

Field report for motivation: the BTCPay Server payjoin plugin (C# over payjoin-ffi) currently treats every exception from a transition save as session-fatal and closes the session, because the only alternative is string-matching display messages. That necessarily kills sessions that failed transiently, e.g. a directory hiccup surfacing through process_response.

Two scope notes:

  • This is the minimal signal available today and doesn't preclude the larger shapes discussed in Ambiguous state when persisting MaybeTerminalSuccessTransition #1709 (TransientWithState, or transient-not-an-error persister outcomes). If one of those lands, the FFI carry-through here still applies to whatever replaces it.
  • SenderPersistedError has the same collapse; happy to follow up on the send side if this direction is agreeable.

Disclosure: co-authored by Claude Code

Pull Request Checklist

Please confirm the following before requesting review:

PersistedError already knows whether the underlying ApiError was
Transient, Fatal, or FatalWithState, but api_error() collapses all
three, so callers cannot tell whether to retry from the same state
or treat the session as closed. Add is_transient() and is_fatal()
accessors.

Carry the distinction through the FFI: ReceiverPersistedError's
Receiver variant becomes Transient and Fatal variants, so bindings
consumers no longer have to treat every persisted error as
session-fatal (or string-match messages) to stay safe. Unclassified
errors map to Fatal, the conservative default for a session-driving
loop.
@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 29009340477

Coverage increased (+0.02%) to 85.889%

Details

  • Coverage increased (+0.02%) from the base build.
  • Patch coverage: 23 of 23 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 15576
Covered Lines: 13378
Line Coverage: 85.89%
Coverage Strength: 350.05 hits per line

💛 - Coveralls

@chavic chavic requested a review from spacebear21 July 9, 2026 11:00
@Mshehu5

Mshehu5 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@ValeraFinebits

ValeraFinebits commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Is https://github.com/payjoin/rust-payjoin/actions/runs/29009340442/job/86088745217?pr=1716 taking 2 hours normal behaviour ?

Definitely not. Windows does it in 7 minutes. I think nix is to blame, since Windows does it without it.

Comment on lines +779 to +784
pub fn is_fatal(&self) -> bool {
matches!(
self.0,
InternalPersistedError::Api(ApiError::Fatal(_) | ApiError::FatalWithState(_, _))
)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems redundant IMO, If is_transient() returns a bool why do we need an additional method to return the inverse?

@chavic chavic Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error is_transient() is_fatal() !is_transient()
Api Transient true false false
Api Fatal / FatalWithState false true true
Storage false false true ← the trap

@benalleng This is kinda how I'm thinking about it, but there's probably a way to simply... You've basically given a litmus test for the API shape in a way

@chavic

chavic commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Is https://github.com/payjoin/rust-payjoin/actions/runs/29009340442/job/86088745217?pr=1716 taking 2 hours normal behaviour ?

It's been like that for about a week... Same locally after the initial download, I think in CI it downloads everytime

@chavic

chavic commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Dug into it, the nix suspicion was right, with a twist. The job isn't slow at building or testing: the script finishes in under five minutes (cargo 4m09s, dotnet tests 8s). The 2+ hours are nix develop .#csharp compiling the .NET SDK from source, nixpkgs' sdk_10_0 is the source-built VMR, which has no Darwin binaries in the public nix cache, and this happens on every macOS run (~150–170 min on every csharp workflow run I sampled, so it long predates this PR). Fix proposed in #1723: use the Microsoft-binary SDK on Darwin only.

@spacebear21

Copy link
Copy Markdown
Collaborator

Superseded by #1724

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants