Skip to main content

kasapay_core/
error.rs

1//! The one error every provider reports through.
2
3use std::error::Error as StdError;
4use std::fmt;
5
6use crate::provider::ProviderId;
7
8/// What went wrong, in terms a caller can branch on without knowing the provider.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10#[non_exhaustive]
11pub enum ErrorKind {
12    /// Credentials were missing, wrong, or not allowed to do this.
13    Auth,
14    /// The request was rejected before it reached the card network.
15    InvalidRequest,
16    /// The bank or the network refused the payment.
17    Declined,
18    /// The payment was not found, or no longer exists.
19    NotFound,
20    /// The provider asked us to slow down.
21    RateLimited,
22    /// The request never got a usable answer: DNS, TLS, timeout, socket.
23    Transport,
24    /// The provider answered, but with something this crate cannot read.
25    Malformed,
26    /// The answer could not be shown to have come from the provider.
27    ///
28    /// A signature that does not match, or one that is missing where the
29    /// provider always sends it. Not a transport failure and not a decline: it
30    /// means the message may not be theirs, and it must never be acted on.
31    Untrusted,
32    /// The provider does not offer what was asked of it.
33    Unsupported,
34    /// The provider failed on its own side.
35    Provider,
36}
37
38impl ErrorKind {
39    /// Whether replaying the same request unchanged could plausibly succeed.
40    ///
41    /// # This does not mean the retry is safe
42    ///
43    /// It says the failure was not a verdict. It says nothing about whether
44    /// the first attempt took the money — a timeout is exactly the case where
45    /// nobody knows.
46    ///
47    /// Replaying a **charge** is safe only where the provider offers
48    /// idempotency, and not every one does:
49    ///
50    /// | | replaying a charge |
51    /// |---|---|
52    /// | Stripe | safe — `ChargeRequest::idempotency_key` is sent as `Idempotency-Key` |
53    /// | iyzico | **not documented safe** — it refuses an idempotency key, and does not say what a reused `orderId` does |
54    /// | PayTR | **not documented safe** — no idempotency mechanism is documented for opening a payment |
55    /// | Mollie | safe — `ChargeRequest::idempotency_key` is sent as `Idempotency-Key`, and Mollie replays the first answer for an hour |
56    /// | PayPal | safe — `ChargeRequest::idempotency_key` is sent as `PayPal-Request-Id`, and PayPal returns the first answer for a repeated key |
57    ///
58    /// Where it is not safe, read the payment back before sending it again.
59    /// Reading is always safe — but **which call reads it back depends on what
60    /// you still have.** A charge that answered and then failed later leaves a
61    /// [`Charge::id`](crate::Charge::id), and
62    /// [`Provider::charge_status`](crate::Provider::charge_status) takes it. A
63    /// charge whose *answer never arrived* leaves nothing but the order
64    /// reference, and that is
65    /// [`Provider::lookup`](crate::Provider::lookup) — which is exactly the
66    /// timeout case, and exactly the two providers whose replay is not
67    /// documented as safe: iyzico and PayTR can both be asked.
68    ///
69    /// **Replaying a capture is a narrower question, because a capture takes
70    /// money rather than opening a request for it.**
71    /// [`Provider::capture`](crate::Provider::capture) carries its own
72    /// `idempotency`, and what a timeout means depends on whether one was
73    /// sent:
74    ///
75    /// | | replaying a capture, with a key | replaying a capture, without one |
76    /// |---|---|---|
77    /// | Stripe | safe — sent as `Idempotency-Key`, same as a charge | **not safe** — a second PaymentIntent capture can take the funds twice |
78    /// | iyzico | n/a — a key is refused outright, because iyzico accepts none | **not safe** — `classic`'s capture is `/payment/postauth`, and iyzico documents no idempotency mechanism for it |
79    /// | PayTR | n/a — no capture step; the hosted form takes the money as it goes | n/a |
80    /// | Mollie | safe — sent as `Idempotency-Key` on the captures endpoint, answered from the cache for an hour | **not safe** — a second capture against the same authorisation can take the funds twice |
81    /// | PayPal | safe — sent as `PayPal-Request-Id`, same as opening an order | **not safe, and PayPal documents it** — a second capture of the same order can take the funds twice |
82    ///
83    /// Where it is not safe, this table's answer does not change: read the
84    /// payment back with
85    /// [`Provider::charge_status`](crate::Provider::charge_status) rather
86    /// than sending [`Provider::capture`](crate::Provider::capture) again. A
87    /// capture whose outcome `is_retryable` does not resolve is read back,
88    /// never resent — with a key, resending is safe but reading is still
89    /// simpler and costs nothing extra.
90    #[must_use]
91    pub const fn is_retryable(self) -> bool {
92        matches!(self, Self::RateLimited | Self::Transport | Self::Provider)
93    }
94}
95
96impl fmt::Display for ErrorKind {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        let text = match self {
99            Self::Auth => "authentication",
100            Self::InvalidRequest => "invalid request",
101            Self::Declined => "declined",
102            Self::NotFound => "not found",
103            Self::RateLimited => "rate limited",
104            Self::Transport => "transport",
105            Self::Malformed => "malformed response",
106            Self::Untrusted => "unverified response",
107            Self::Unsupported => "unsupported",
108            Self::Provider => "provider failure",
109        };
110        f.write_str(text)
111    }
112}
113
114/// A payment operation failed.
115#[derive(Debug)]
116pub struct Error {
117    kind: ErrorKind,
118    provider: ProviderId,
119    message: Box<str>,
120    code: Option<Box<str>>,
121    source: Option<Box<dyn StdError + Send + Sync>>,
122}
123
124impl Error {
125    /// Builds an error attributed to a provider.
126    pub fn new(kind: ErrorKind, provider: ProviderId, message: impl Into<Box<str>>) -> Self {
127        Self {
128            kind,
129            provider,
130            message: message.into(),
131            code: None,
132            source: None,
133        }
134    }
135
136    /// Attaches the provider's own error code, verbatim.
137    #[must_use]
138    pub fn with_code(mut self, code: impl Into<Box<str>>) -> Self {
139        self.code = Some(code.into());
140        self
141    }
142
143    /// Attaches the underlying error.
144    #[must_use]
145    pub fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self {
146        self.source = Some(Box::new(source));
147        self
148    }
149
150    /// What went wrong.
151    #[must_use]
152    pub const fn kind(&self) -> ErrorKind {
153        self.kind
154    }
155
156    /// Which provider reported it.
157    #[must_use]
158    pub const fn provider(&self) -> ProviderId {
159        self.provider
160    }
161
162    /// The provider's own error code, when it gave one.
163    #[must_use]
164    pub fn code(&self) -> Option<&str> {
165        self.code.as_deref()
166    }
167
168    /// Whether replaying the same request unchanged could plausibly succeed.
169    #[must_use]
170    pub const fn is_retryable(&self) -> bool {
171        self.kind.is_retryable()
172    }
173}
174
175impl fmt::Display for Error {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        write!(f, "{}: {} ({})", self.provider, self.message, self.kind)?;
178        if let Some(code) = &self.code {
179            write!(f, " [{code}]")?;
180        }
181        Ok(())
182    }
183}
184
185impl StdError for Error {
186    fn source(&self) -> Option<&(dyn StdError + 'static)> {
187        self.source
188            .as_ref()
189            .map(|e| &**e as &(dyn StdError + 'static))
190    }
191}