resend_rs/
error.rs

1#[allow(unreachable_pub)]
2pub mod types {
3    use serde::Deserialize;
4
5    /// Error returned as a response.
6    ///
7    /// <https://resend.com/docs/api-reference/errors>
8    #[derive(Debug, Clone, Deserialize, thiserror::Error)]
9    #[error("{name}: {message}")]
10    pub struct ErrorResponse {
11        #[serde(rename = "statusCode")]
12        pub status_code: u16,
13        pub message: String,
14        pub name: String,
15    }
16
17    impl ErrorResponse {
18        /// Returns the [`ErrorKind`].
19        #[must_use]
20        pub fn kind(&self) -> ErrorKind {
21            ErrorKind::from(self.name.as_str())
22        }
23    }
24
25    /// Error type for operations of a [`Resend`] client.
26    ///
27    /// <https://resend.com/docs/api-reference/errors>
28    ///
29    /// [`Resend`]: crate::Resend
30    #[non_exhaustive]
31    #[derive(Debug, Copy, Clone, PartialEq, Eq)]
32    #[cfg_attr(test, derive(strum::EnumCount))]
33    pub enum ErrorKind {
34        /// Error name is not in the API spec.
35        Unrecognized,
36
37        /// 400 Bad Request.
38        ///
39        /// - `invalid_idempotency_key`
40        ///
41        /// The key must be between 1-256 chars.
42        ///
43        /// Retry with a valid idempotency key.
44        InvalidIdempotencyKey,
45
46        /// 400 Bad Request.
47        ///
48        /// - `validation_error`
49        ///
50        /// We found an error with one or more fields in the request.
51        ///
52        /// The message will contain more details about what field and error were found.
53        ValidationError400,
54
55        /// 401 Unauthorized.
56        ///
57        /// - `missing_api_key`
58        ///
59        /// Missing API key in the authorization header.
60        ///
61        /// Include the following header `Authorization: Bearer YOUR_API_KEY` in the request.
62        MissingApiKey,
63
64        /// 401 Unauthorized
65        ///
66        /// - `restricted_api_key`
67        ///
68        /// This API key is restricted to only send emails.
69        ///
70        /// Make sure the API key has `Full access` to perform actions other than sending emails.
71        RestrictedApiKey,
72
73        /// 403 Forbidden.
74        ///
75        /// - `invalid_api_key`
76        ///
77        /// API key is invalid.
78        ///
79        /// Make sure the API key is correct or generate a new [API key in the dashboard].
80        ///
81        /// [API key in the dashboard]: https://resend.com/api-keys
82        InvalidApiKey,
83
84        /// 403 Forbidden.
85        ///
86        /// - `validation_error`
87        ///
88        /// You can only send testing emails to your own email address (`youremail@domain.com`).
89        ///
90        /// In [Resend's Domain page], add and verify a domain for
91        /// which you have DNS access. This allows you to send emails to addresses beyond your own.
92        ///
93        /// [Resend's Domain page]: https://resend.com/domains
94        ValidationError403,
95
96        /// 404 Not Found.
97        ///
98        /// - `not_found`
99        ///
100        /// The requested endpoint does not exist.
101        ///
102        /// Change your request URL to match a valid API endpoint.
103        NotFound,
104
105        /// 405 Method Not Allowed.
106        ///
107        /// - `method_not_allowed`
108        ///
109        /// Method is not allowed for the requested path.
110        ///
111        /// Change your API endpoint to use a valid method.
112        MethodNotAllowed,
113
114        /// 409 Conflict
115        ///
116        /// - `invalid_idempotent_request`
117        ///
118        /// Same idempotency key used with a different request payload.
119        ///
120        /// Change your idempotency key or payload.
121        InvalidIdempotentRequest,
122
123        /// 409 Conflict
124        ///
125        /// - `concurrent_idempotent_requests`
126        ///
127        /// Same idempotency key used while original request is still in progress.
128        ///
129        /// Try the request again later.
130        ConcurrentIdempotentRequests,
131
132        /// 422 Unprocessable Content.
133        ///
134        /// - `invalid_attachment`
135        ///
136        /// Attachment must have either a `content` or `path`.
137        ///
138        /// Attachments must either have a `content` (strings, Buffer, or Stream contents) or
139        /// `path` to a remote resource (better for larger attachments).
140        InvalidAttachment,
141
142        /// 422 Unprocessable Content.
143        ///
144        /// - `invalid_from_address`
145        ///
146        /// Invalid from field.
147        ///
148        /// Make sure the from field is a valid. The email address needs to follow the
149        /// `email@example.com` or `Name <email@example.com>` format.
150        InvalidFromAddress,
151
152        /// 422 Unprocessable Content
153        ///
154        /// - `invalid_access`
155        ///
156        /// Access must be `"full_access" | "sending_access"`.
157        ///
158        /// Make sure the API key has necessary permissions.
159        InvalidAccess,
160
161        /// 422 Unprocessable Content
162        ///
163        /// - `invalid_parameter`
164        ///
165        /// The parameter must be a valid UUID.
166        ///
167        /// Check the value and make sure it’s valid.
168        InvalidParameter,
169
170        /// 422 Unprocessable Content
171        ///
172        /// - `invalid_region`
173        ///
174        /// Region must be `"us-east-1" | "us-east-1" | "sa-east-1"`.
175        ///
176        /// Make sure the correct region is selected.
177        InvalidRegion,
178
179        /// 422 Unprocessable Content.
180        ///
181        /// - `missing_required_field`
182        ///
183        /// The request body is missing one or more required fields.
184        ///
185        /// Check the error message to see the list of missing fields.
186        MissingRequiredField,
187
188        /// 429 Too Many Requests.
189        ///
190        /// - `monthly_quota_exceeded`
191        ///
192        /// You have reached your monthly email sending quota.
193        ///
194        ///  Upgrade your plan to remove the increase the monthly sending limit.
195        MonthlyQuotaExceeded,
196
197        /// 429 Too Many Requests.
198        ///
199        /// - `daily_quota_exceeded`
200        ///
201        /// You have reached your daily email sending quota.
202        ///
203        /// Upgrade your plan to remove the daily quota limit or wait
204        /// until 24 hours have passed to continue sending.
205        DailyQuotaExceeded,
206
207        /// 429 Too Many Requests.
208        ///
209        /// - `rate_limit_exceeded`
210        ///
211        /// Too many requests. Please limit the number of requests per second.
212        /// Or contact support to increase rate limit.
213        ///
214        /// You should read the response headers and reduce the rate at which you request the API.
215        /// This can be done by introducing a queue mechanism or reducing the number of concurrent
216        /// requests per second. If you have specific requirements, contact support to request a
217        /// rate increase.
218        ///
219        /// ## Note
220        ///
221        /// This should *never* be returned anymore as it's been replaced by the more detailed
222        /// [`Error::RateLimit`](crate::Error::RateLimit).
223        RateLimitExceeded,
224
225        /// 451 Unavailable For Legal Reasons
226        ///
227        /// - `security_error`
228        ///
229        /// We may have found a security issue with the request.
230        ///
231        /// The message will contain more details. Contact support for more information.
232        SecurityError,
233
234        /// 500 Internal Server Error
235        ///
236        /// - `application_error`
237        ///
238        /// An unexpected error occurred.
239        ///
240        /// Try the request again later. If the error does not resolve, check our status page
241        /// for service updates.
242        ApplicationError,
243
244        /// 500 Internal Server Error.
245        ///
246        /// - `internal_server_error`
247        ///
248        /// An unexpected error occurred.
249        ///
250        /// Try the request again later. If the error does not resolve,
251        /// check our [`status page`] for service updates.
252        ///
253        /// [`status page`]: https://resend-status.com/
254        InternalServerError,
255    }
256
257    impl From<ErrorResponse> for ErrorKind {
258        fn from(value: ErrorResponse) -> Self {
259            // There exist 2 validation_error variants, differentiate via status code
260            if value.name == "validation_error" {
261                return match value.status_code {
262                    400 => Self::ValidationError400,
263                    403 => Self::ValidationError403,
264                    _ => Self::Unrecognized,
265                };
266            }
267
268            // For the rest use old From implementation.
269            Self::from(value.name)
270        }
271    }
272
273    impl<T: AsRef<str>> From<T> for ErrorKind {
274        fn from(value: T) -> Self {
275            match value.as_ref() {
276                "invalid_idempotency_key" => Self::InvalidIdempotencyKey,
277                "missing_api_key" => Self::MissingApiKey,
278                "restricted_api_key" => Self::RestrictedApiKey,
279                "invalid_api_key" => Self::InvalidApiKey,
280                "not_found" => Self::NotFound,
281                "method_not_allowed" => Self::MethodNotAllowed,
282                "invalid_idempotent_request" => Self::InvalidIdempotentRequest,
283                "concurrent_idempotent_requests" => Self::ConcurrentIdempotentRequests,
284                "invalid_attachment" => Self::InvalidAttachment,
285                "invalid_from_address" => Self::InvalidFromAddress,
286                "invalid_access" => Self::InvalidAccess,
287                "invalid_parameter" => Self::InvalidParameter,
288                "invalid_region" => Self::InvalidRegion,
289                "missing_required_field" => Self::MissingRequiredField,
290                "monthly_quota_exceeded" => Self::MonthlyQuotaExceeded,
291                "daily_quota_exceeded" => Self::DailyQuotaExceeded,
292                "rate_limit_exceeded" => Self::RateLimitExceeded,
293                "security_error" => Self::SecurityError,
294                "application_error" => Self::ApplicationError,
295                "internal_server_error" => Self::InternalServerError,
296                _ => Self::Unrecognized,
297            }
298        }
299    }
300}
301
302#[cfg(test)]
303mod test {
304    /// This test parses [all Resend errors] and makes sure [`crate::types::ErrorKind`] models
305    /// them correctly, namely:
306    ///
307    /// - No error is parsed as [`crate::types::ErrorKind::Unrecognized`] (they are all recognized)
308    /// - The amount of errors from the website + 1 (for the unrecognized variant) is equal to the
309    ///   number of error variants in [`crate::types::ErrorKind`].
310    ///
311    /// There is a very real chance this will break in the future if anything changes in the
312    /// structure of the errors page but for now it is useful to have to make sure all errors are
313    /// modelled in the code.
314    ///
315    /// [all Resend errors]: https://resend.com/docs/api-reference/errors
316    #[allow(clippy::unwrap_used)]
317    #[tokio_shared_rt::test(shared = true)]
318    #[cfg(not(feature = "blocking"))]
319    async fn errors_up_to_date() {
320        use strum::EnumCount;
321
322        use crate::types::{ErrorKind, ErrorResponse};
323
324        let response = reqwest::get("https://resend.com/docs/api-reference/errors")
325            .await
326            .unwrap();
327
328        let html = response.text().await.unwrap();
329
330        let fragment = scraper::Html::parse_document(&html);
331        let selector = scraper::Selector::parse("h3 > span").unwrap();
332
333        let re = regex::Regex::new(r"<code>(\w+)</code>").unwrap();
334
335        let actual = ErrorKind::COUNT;
336        let expected = fragment
337            .select(&selector)
338            .map(|el| el.inner_html())
339            .map(|inner| {
340                let mut results = vec![];
341                for (_, [error]) in re.captures_iter(&inner).map(|c| c.extract()) {
342                    results.push(error.to_string());
343                }
344                results
345            })
346            .collect::<Vec<_>>();
347
348        // Make sure no error is parsed as `ErrorKind::Unrecognized`
349        for error_name in expected.iter().flatten() {
350            let error_response = ErrorResponse {
351                status_code: 400,
352                message: String::new(),
353                name: error_name.clone(),
354            };
355
356            let error_kind = ErrorKind::from(error_response);
357            assert!(
358                !matches!(error_kind, ErrorKind::Unrecognized),
359                "Could not parse {error_name}"
360            );
361        }
362
363        // Expected is actually one less than what we have because of the `Unrecognized` variant.
364        let expected = expected.len() + 1;
365
366        assert_eq!(actual, expected);
367    }
368}