-
Notifications
You must be signed in to change notification settings - Fork 77
FFI: Eliminate string fallbacks in error wrappers #1127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
FFI: Eliminate string fallbacks in error wrappers #1127
Conversation
Pull Request Test Coverage Report for Build 18389775642Details
💛 - Coveralls |
payjoin-ffi/src/receive/error.rs
Outdated
| let mut err = Some(err); | ||
|
|
||
| if err.as_ref().and_then(|e| e.storage_error_ref()).is_some() { | ||
| if let Some(storage_err) = err.take().and_then(|e| e.storage_error()) { | ||
| return ReceiverPersistedError::from(ImplementationError::new(storage_err)); | ||
| } | ||
| return ReceiverPersistedError::Receiver(ReceiverError::Unexpected); | ||
| } | ||
|
|
||
| let err = err.expect("PersistedError consumed before extracting API error"); | ||
|
|
||
| if let Some(api_err) = err.api_error() { | ||
| return ReceiverPersistedError::Receiver($receiver_arm(api_err)); | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks more complicated then before, what is the simplification?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I encountered this issue... When we get payjoin::persist::PersistedError, we occasionally need both:
- A peek to see whether it contains a storage error (storage_error_ref), and
- Ownership of the same value so we can pull that storage error out (storage_error), or fall back to api_error.
The borrow checker won’t let us call storage_error_ref() and then later move the same err without more juggling. Wrapping it in Some(err) gives us a tiny state machine.
It’s a bit more code than checking twice, but it keeps us from cloning errors or double-consuming the iterator.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Other approaches would be to....
- Require every storage error type to implement Clone so we can copy it off the reference (not ideal), or
- Restructure the code so we inspect and consume the PersistedError exactly once.
I'll try the second approach first...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Restructuring the code so that we inspect and consume the PersistedError exactly once seems to introduce some unusual semantics, such as Ok(api_err). I'm open to other ideas for now... @spacebear21
// payjoin-ffi/src/persist.rs
use payjoin::persist::PersistedError;
pub enum PersistedSplit<ApiErr, StorageErr> {
Api(ApiErr),
Storage(StorageErr),
Unknown,
}
pub fn split_persisted_error<ApiErr, StorageErr>(
err: PersistedError<ApiErr, StorageErr>,
) -> PersistedSplit<ApiErr, StorageErr>
where
ApiErr: std::error::Error,
StorageErr: std::error::Error,
{
// `into` gives us the internal enum, so we can match without cloning.
match err.into() {
payjoin::persist::InternalPersistedError::Transient(api)
| payjoin::persist::InternalPersistedError::Fatal(api) => PersistedSplit::Api(api),
payjoin::persist::InternalPersistedError::Storage(storage) => PersistedSplit::Storage(storage),
}
}on the receiving side... sender is somewhat identical...
use crate::persist::{split_persisted_error, PersistedSplit};
impl<S> From<PersistedError<payjoin::receive::Error, S>> for ReceiverPersistedError
where
S: std::error::Error + Send + Sync + 'static,
{
fn from(err: PersistedError<payjoin::receive::Error, S>) -> Self {
match split_persisted_error(err) {
PersistedSplit::Api(api) => ReceiverPersistedError::Receiver(api.into()),
PersistedSplit::Storage(storage) =>
ReceiverPersistedError::from(ImplementationError::new(storage)),
PersistedSplit::Unknown =>
ReceiverPersistedError::Receiver(ReceiverError::Unexpected),
}
}
}2d05666 to
d6535ff
Compare
payjoin-ffi/src/error.rs
Outdated
| #[derive(Debug, thiserror::Error, uniffi::Object)] | ||
| #[error(transparent)] | ||
| pub struct ImplementationError(#[from] payjoin::ImplementationError); | ||
| pub struct ImplementationExceptionInner(#[from] payjoin::ImplementationError); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand why ImplementationError was changed to ImplementationExceptionInner with an outer enum wrapper? This seems to do exactly the same thing that ForeignError in a slightly re-arranged way. I thought the point of getting rid of ForeignError was so we could return ImplementationError directly. If that's not possible I'd rather not change it at all.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ohh yeah… so errors have to be exported as uniffi::Error enums, while anything you hand back for callbacks or stored handles has to be an uniffi::Object. UniFFI won’t let one type play both roles, so we tuck the core payjoin::ImplementationError inside that inner object and let the enum carry it across the error boundary.
|
|
||
| impl From<String> for PjParseError { | ||
| fn from(msg: String) -> Self { PjParseError { msg } } | ||
| impl PjParseError { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like what you did with OhttpError, changing it to:
pub struct OhttpError(#[from] ohttp::Error);Is there something preventing us from doing the same thing for PjParseError and PjNotSupported? If yes it would be good to document why we take a different approach for these.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I went string-backed here because the core payjoin::PjParseError/PjNotSupported don’t currently derive PartialEq/Eq; our fixtures check these errors by comparing values, so swapping to a transparent wrapper would break those comparisons unless we taught the core types how to compare or wrote custom equality glue.
Without those traits, the generated bindings—and our existing tests that compare errors—would break once we stop stringifying.
22de373 to
5b352cb
Compare
7e699ea to
ded2e6d
Compare
|
I squashed superfluous commits together and reverted the changes to The second commit here does solve the immediate problem of exposing |
spacebear21
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ACK ded2e6d
This drops the last string-based conversions in the FFI surface. OhttpError now wraps the upstream ohttp::Error directly (with a simple message() helper for bindings), PjParseError and PjNotSupported carry the typed payjoin errors via internal constructors, and drops ForeignError call site now routes through ImplementationError::new(e).
This addresses issue #1117
AI Disclosure: Used GPT-5 Codex for multi-file edits in Cursor