Skip to main content

resend_rs/
error.rs

1#[allow(unreachable_pub)]
2pub mod types {
3    use serde::{Deserialize, Serialize};
4
5    /// Error returned as a response.
6    ///
7    /// <https://resend.com/docs/api-reference/errors>
8    #[derive(Debug, Clone, Serialize, 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        /// One of the following:
89        /// - <https://resend.com/docs/api-reference/errors#validation_error-2>
90        /// - <https://resend.com/docs/api-reference/errors#validation_error-3>
91        /// - <https://resend.com/docs/api-reference/errors#validation_error-4>
92        ValidationError403,
93
94        /// 404 Not Found.
95        ///
96        /// - `not_found`
97        ///
98        /// The requested endpoint does not exist.
99        ///
100        /// Change your request URL to match a valid API endpoint.
101        NotFound,
102
103        /// 405 Method Not Allowed.
104        ///
105        /// - `method_not_allowed`
106        ///
107        /// Method is not allowed for the requested path.
108        ///
109        /// Change your API endpoint to use a valid method.
110        MethodNotAllowed,
111
112        /// 409 Conflict
113        ///
114        /// - `invalid_idempotent_request`
115        ///
116        /// Same idempotency key used with a different request payload.
117        ///
118        /// Change your idempotency key or payload.
119        InvalidIdempotentRequest,
120
121        /// 409 Conflict
122        ///
123        /// - `concurrent_idempotent_requests`
124        ///
125        /// Same idempotency key used while original request is still in progress.
126        ///
127        /// Try the request again later.
128        ConcurrentIdempotentRequests,
129
130        /// 422 Unprocessable Content.
131        ///
132        /// - `invalid_attachment`
133        ///
134        /// Attachment must have either a `content` or `path`.
135        ///
136        /// Attachments must either have a `content` (strings, Buffer, or Stream contents) or
137        /// `path` to a remote resource (better for larger attachments).
138        InvalidAttachment,
139
140        /// 422 Unprocessable Content.
141        ///
142        /// - `invalid_from_address`
143        ///
144        /// Invalid from field.
145        ///
146        /// Make sure the from field is a valid. The email address needs to follow the
147        /// `email@example.com` or `Name <email@example.com>` format.
148        InvalidFromAddress,
149
150        /// 422 Unprocessable Content
151        ///
152        /// - `invalid_access`
153        ///
154        /// Access must be `"full_access" | "sending_access"`.
155        ///
156        /// Make sure the API key has necessary permissions.
157        InvalidAccess,
158
159        /// 422 Unprocessable Content
160        ///
161        /// - `invalid_parameter`
162        ///
163        /// The parameter must be a valid UUID.
164        ///
165        /// Check the value and make sure it’s valid.
166        InvalidParameter,
167
168        /// 422 Unprocessable Content
169        ///
170        /// - `invalid_region`
171        ///
172        /// Region must be `"us-east-1" | "us-east-1" | "sa-east-1"`.
173        ///
174        /// Make sure the correct region is selected.
175        InvalidRegion,
176
177        /// 422 Unprocessable Content.
178        ///
179        /// - `missing_required_field`
180        ///
181        /// The request body is missing one or more required fields.
182        ///
183        /// Check the error message to see the list of missing fields.
184        MissingRequiredField,
185
186        /// 429 Too Many Requests.
187        ///
188        /// - `monthly_quota_exceeded`
189        ///
190        /// You have reached your monthly email sending quota.
191        ///
192        ///  Upgrade your plan to remove the increase the monthly sending limit.
193        MonthlyQuotaExceeded,
194
195        /// 429 Too Many Requests.
196        ///
197        /// - `daily_quota_exceeded`
198        ///
199        /// You have reached your daily email sending quota.
200        ///
201        /// Upgrade your plan to remove the daily quota limit or wait
202        /// until 24 hours have passed to continue sending.
203        DailyQuotaExceeded,
204
205        /// 429 Too Many Requests.
206        ///
207        /// - `rate_limit_exceeded`
208        ///
209        /// Too many requests. Please limit the number of requests per second.
210        /// Or contact support to increase rate limit.
211        ///
212        /// You should read the response headers and reduce the rate at which you request the API.
213        /// This can be done by introducing a queue mechanism or reducing the number of concurrent
214        /// requests per second. If you have specific requirements, contact support to request a
215        /// rate increase.
216        ///
217        /// ## Note
218        ///
219        /// This should *never* be returned anymore as it's been replaced by the more detailed
220        /// [`Error::RateLimit`](crate::Error::RateLimit).
221        RateLimitExceeded,
222
223        /// 451 Unavailable For Legal Reasons
224        ///
225        /// - `security_error`
226        ///
227        /// We may have found a security issue with the request.
228        ///
229        /// The message will contain more details. Contact support for more information.
230        SecurityError,
231
232        /// 500 Internal Server Error
233        ///
234        /// - `application_error`
235        ///
236        /// An unexpected error occurred.
237        ///
238        /// Try the request again later. If the error does not resolve, check our status page
239        /// for service updates.
240        ApplicationError,
241
242        /// 500 Internal Server Error.
243        ///
244        /// - `internal_server_error`
245        ///
246        /// An unexpected error occurred.
247        ///
248        /// Try the request again later. If the error does not resolve,
249        /// check our [`status page`] for service updates.
250        ///
251        /// [`status page`]: https://resend-status.com/
252        InternalServerError,
253    }
254
255    impl From<ErrorResponse> for ErrorKind {
256        fn from(value: ErrorResponse) -> Self {
257            // There exist 2 validation_error variants, differentiate via status code
258            if value.name == "validation_error" {
259                return match value.status_code {
260                    400 => Self::ValidationError400,
261                    // This is a bit silly, since we have 2 validation errors with the same error
262                    // code, we need to differentiate between them based on the message.
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            .filter(|el| el.starts_with("<code>"))
340            .map(|inner| {
341                let mut results = vec![];
342                for (_, [error]) in re.captures_iter(&inner).map(|c| c.extract()) {
343                    results.push(error.to_string());
344                }
345                results
346            })
347            .collect::<Vec<_>>();
348
349        // Make sure no error is parsed as `ErrorKind::Unrecognized`
350        for error_name in expected.iter().flatten() {
351            let error_response = ErrorResponse {
352                status_code: 400,
353                message: String::new(),
354                name: error_name.clone(),
355            };
356
357            let error_kind = ErrorKind::from(error_response);
358            assert!(
359                !matches!(error_kind, ErrorKind::Unrecognized),
360                "Could not parse {error_name}"
361            );
362        }
363
364        // Expected is actually one less than what we have because of the `Unrecognized` variant.
365        let expected = expected.len() - 1;
366
367        assert_eq!(actual, expected);
368    }
369}