Skip to main content

pakasir_sdk/
error.rs

1// Copyright 2026 H0llyW00dzZ
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Crate-wide error type.
16//!
17//! Everything fallible in this SDK ends up as an [`enum@Error`]. The
18//! variants are kept flat so callers can match on them without juggling
19//! nested types.
20//!
21//! Most variants carry a pre-localized `message` so a plain `{err}` print is
22//! already in the configured language. Variants that wrap an underlying
23//! error use `#[source]` so [`std::error::Error::source`] walks the chain
24//! cleanly.
25
26use reqwest::StatusCode;
27use std::error::Error as StdError;
28use thiserror::Error;
29
30use crate::i18n::{self, Language, MessageKey};
31
32/// Boxed error used internally to keep retry plumbing object-safe.
33pub type BoxError = Box<dyn StdError + Send + Sync>;
34/// Convenience `Result` alias bound to [`enum@Error`].
35pub type Result<T> = std::result::Result<T, Error>;
36
37/// Every error the SDK can produce.
38#[derive(Debug, Error)]
39pub enum Error {
40    /// The project slug is empty. Triggered by [`crate::Client::do_request`]
41    /// before any network call.
42    #[error("{message}")]
43    InvalidProject { message: String },
44    /// The API key is empty. Triggered by [`crate::Client::do_request`]
45    /// before any network call.
46    #[error("{message}")]
47    InvalidApiKey { message: String },
48    /// `amount` was zero or negative.
49    #[error("{message}")]
50    InvalidAmount { message: String },
51    /// `order_id` was empty.
52    #[error("{message}")]
53    InvalidOrderId { message: String },
54    /// A payment method outside the supported set was used. Currently unused
55    /// because the typed [`crate::PaymentMethod`] enum makes invalid values
56    /// unrepresentable, but kept here so future runtime checks have a slot
57    /// to land in.
58    #[error("{message}")]
59    InvalidPaymentMethod { message: String },
60    /// `serde_json` refused to encode the request body.
61    #[error("{message}: {source}")]
62    EncodeJson {
63        message: String,
64        #[source]
65        source: serde_json::Error,
66    },
67    /// `serde_json` refused to decode the response body.
68    #[error("{message}: {source}")]
69    DecodeJson {
70        message: String,
71        #[source]
72        source: serde_json::Error,
73    },
74    /// The configured base URL combined with a path did not parse as a URL.
75    #[error("client: failed to create request: {source}")]
76    BuildRequest {
77        #[source]
78        source: url::ParseError,
79    },
80    /// The API returned a non-2xx response.
81    ///
82    /// `body` is the raw response body decoded as UTF-8 with lossy
83    /// substitution. Use [`Error::api_status`] to read the status code
84    /// programmatically.
85    #[error("pakasir api error: status {status}: {body}")]
86    Api { status: StatusCode, body: String },
87    /// A request hit a permanent transport-level failure (TLS, invalid URL
88    /// after the builder accepted it, response too large, …).
89    #[error("{message}: {source}")]
90    RequestFailed {
91        message: String,
92        #[source]
93        source: BoxError,
94    },
95    /// The retry loop ran out of attempts. `source` is the last transient
96    /// failure observed.
97    #[error("{message}: {source}")]
98    RequestFailedAfterRetries {
99        message: String,
100        #[source]
101        source: BoxError,
102    },
103    /// The response body exceeded the configured size cap. Returned without
104    /// fully buffering the offending response.
105    #[error("response body too large: exceeds {limit} bytes")]
106    ResponseTooLarge { limit: usize },
107}
108
109impl Error {
110    /// Build an [`Error::InvalidProject`] in the given language.
111    pub(crate) fn invalid_project(lang: Language) -> Self {
112        Self::InvalidProject {
113            message: i18n::get(lang, MessageKey::InvalidProject).to_owned(),
114        }
115    }
116
117    /// Build an [`Error::InvalidApiKey`] in the given language.
118    pub(crate) fn invalid_api_key(lang: Language) -> Self {
119        Self::InvalidApiKey {
120            message: i18n::get(lang, MessageKey::InvalidApiKey).to_owned(),
121        }
122    }
123
124    /// Build an [`Error::InvalidAmount`] in the given language.
125    pub(crate) fn invalid_amount(lang: Language) -> Self {
126        Self::InvalidAmount {
127            message: i18n::get(lang, MessageKey::InvalidAmount).to_owned(),
128        }
129    }
130
131    /// Build an [`Error::InvalidOrderId`] in the given language.
132    pub(crate) fn invalid_order_id(lang: Language) -> Self {
133        Self::InvalidOrderId {
134            message: i18n::get(lang, MessageKey::InvalidOrderId).to_owned(),
135        }
136    }
137
138    /// Build an [`Error::EncodeJson`] from a `serde_json` failure.
139    pub(crate) fn encode_json(lang: Language, source: serde_json::Error) -> Self {
140        Self::EncodeJson {
141            message: i18n::get(lang, MessageKey::FailedToEncode).to_owned(),
142            source,
143        }
144    }
145
146    /// Build an [`Error::DecodeJson`] from a `serde_json` failure.
147    pub(crate) fn decode_json(lang: Language, source: serde_json::Error) -> Self {
148        Self::DecodeJson {
149            message: i18n::get(lang, MessageKey::FailedToDecode).to_owned(),
150            source,
151        }
152    }
153
154    /// Wrap a permanent transport failure in [`Error::RequestFailed`].
155    pub(crate) fn request_failed(lang: Language, source: BoxError) -> Self {
156        Self::RequestFailed {
157            message: i18n::get(lang, MessageKey::RequestFailedPermanent).to_owned(),
158            source,
159        }
160    }
161
162    /// Wrap "retries exhausted" in [`Error::RequestFailedAfterRetries`].
163    ///
164    /// The `%d` placeholder in the localized template is replaced with the
165    /// configured retry count.
166    pub(crate) fn request_failed_after_retries(
167        lang: Language,
168        retries: usize,
169        source: BoxError,
170    ) -> Self {
171        let template = i18n::get(lang, MessageKey::RequestFailedAfterRetries);
172        let message = template.replacen("%d", &retries.to_string(), 1);
173        Self::RequestFailedAfterRetries { message, source }
174    }
175
176    /// Return the HTTP status when this is an [`Error::Api`], otherwise
177    /// `None`. Handy for branch logic without having to `match` on the full
178    /// enum.
179    pub fn api_status(&self) -> Option<StatusCode> {
180        match self {
181            Self::Api { status, .. } => Some(*status),
182            _ => None,
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn make_serde_error() -> serde_json::Error {
192        serde_json::from_slice::<serde_json::Value>(b"not json").unwrap_err()
193    }
194
195    #[test]
196    fn invalid_project_uses_localized_message() {
197        let err = Error::invalid_project(Language::English);
198        assert_eq!(err.to_string(), "project slug is required");
199
200        let err = Error::invalid_project(Language::Indonesian);
201        assert_eq!(err.to_string(), "slug proyek wajib diisi");
202    }
203
204    #[test]
205    fn invalid_api_key_uses_localized_message() {
206        let err = Error::invalid_api_key(Language::English);
207        assert_eq!(err.to_string(), "API key is required");
208
209        let err = Error::invalid_api_key(Language::Indonesian);
210        assert_eq!(err.to_string(), "API key wajib diisi");
211    }
212
213    #[test]
214    fn invalid_amount_uses_localized_message() {
215        let err = Error::invalid_amount(Language::English);
216        assert_eq!(err.to_string(), "amount must be greater than 0");
217
218        let err = Error::invalid_amount(Language::Indonesian);
219        assert_eq!(err.to_string(), "jumlah harus lebih dari 0");
220    }
221
222    #[test]
223    fn invalid_order_id_uses_localized_message() {
224        let err = Error::invalid_order_id(Language::English);
225        assert_eq!(err.to_string(), "order ID is required");
226
227        let err = Error::invalid_order_id(Language::Indonesian);
228        assert_eq!(err.to_string(), "ID pesanan wajib diisi");
229    }
230
231    #[test]
232    fn encode_json_wraps_source_error_with_localized_prefix() {
233        let err = Error::encode_json(Language::English, make_serde_error());
234        let text = err.to_string();
235        assert!(
236            text.starts_with("failed to encode request as JSON: "),
237            "unexpected: {text}"
238        );
239        assert!(err.source().is_some());
240
241        let err = Error::encode_json(Language::Indonesian, make_serde_error());
242        assert!(
243            err.to_string()
244                .starts_with("gagal mengenkode permintaan sebagai JSON: ")
245        );
246    }
247
248    #[test]
249    fn decode_json_wraps_source_error_with_localized_prefix() {
250        let err = Error::decode_json(Language::English, make_serde_error());
251        let text = err.to_string();
252        assert!(
253            text.starts_with("failed to decode response: "),
254            "unexpected: {text}"
255        );
256        assert!(err.source().is_some());
257
258        let err = Error::decode_json(Language::Indonesian, make_serde_error());
259        assert!(err.to_string().starts_with("gagal mendekode respons: "));
260    }
261
262    #[test]
263    fn request_failed_uses_permanent_template() {
264        let err = Error::request_failed(Language::English, Box::new(std::io::Error::other("boom")));
265        assert!(
266            err.to_string()
267                .starts_with("request failed due to permanent error: ")
268        );
269        assert!(err.source().is_some());
270    }
271
272    #[test]
273    fn request_failed_after_retries_substitutes_count() {
274        let err = Error::request_failed_after_retries(
275            Language::English,
276            3,
277            Box::new(std::io::Error::other("flaky")),
278        );
279        assert!(err.to_string().contains("after 3 retries"));
280
281        let err = Error::request_failed_after_retries(
282            Language::Indonesian,
283            5,
284            Box::new(std::io::Error::other("flaky")),
285        );
286        assert!(err.to_string().contains("setelah 5 percobaan ulang"));
287    }
288
289    #[test]
290    fn api_status_returns_status_for_api_variant() {
291        let err = Error::Api {
292            status: StatusCode::BAD_REQUEST,
293            body: "bad".into(),
294        };
295        assert_eq!(err.api_status(), Some(StatusCode::BAD_REQUEST));
296        assert!(err.to_string().contains("status 400"));
297    }
298
299    #[test]
300    fn api_status_returns_none_for_other_variants() {
301        let err = Error::invalid_project(Language::English);
302        assert_eq!(err.api_status(), None);
303
304        let err = Error::ResponseTooLarge { limit: 1024 };
305        assert_eq!(err.api_status(), None);
306        assert!(err.to_string().contains("1024"));
307    }
308
309    #[test]
310    fn build_request_display_includes_source() {
311        let parse_err = url::Url::parse("not a url").unwrap_err();
312        let err = Error::BuildRequest { source: parse_err };
313        assert!(err.to_string().contains("failed to create request"));
314        assert!(err.source().is_some());
315    }
316
317    #[test]
318    fn invalid_payment_method_display_is_message() {
319        // Currently unused at runtime but kept on the surface; cover its
320        // Display so a future regression in the enum order is caught.
321        let err = Error::InvalidPaymentMethod {
322            message: "bogus".into(),
323        };
324        assert_eq!(err.to_string(), "bogus");
325    }
326}