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(
33        test,
34        derive(strum::EnumCount, strum::VariantNames),
35        strum(serialize_all = "snake_case")
36    )]
37    pub enum ErrorKind {
38        /// Error name is not in the API spec.
39        Unrecognized,
40
41        /// 400 Bad Request.
42        ///
43        /// - `invalid_idempotency_key`
44        ///
45        /// The key must be between 1-256 chars.
46        ///
47        /// Retry with a valid idempotency key.
48        InvalidIdempotencyKey,
49
50        ValidationError,
51
52        /// 401 Unauthorized.
53        ///
54        /// - `missing_api_key`
55        ///
56        /// Missing API key in the authorization header.
57        ///
58        /// Include the following header `Authorization: Bearer YOUR_API_KEY` in the request.
59        MissingApiKey,
60
61        RestrictedApiKey,
62
63        /// 403 Forbidden.
64        ///
65        /// - `email_above_quota`
66        ///
67        /// You can’t retrieve this email’s content because it was above quota when received.
68        ///
69        /// [Upgrade your plan] to increase your quota.
70        ///
71        /// [Upgrade your plan]: https://resend.com/settings/billing
72        EmailAboveQuota,
73
74        /// 403 Forbidden.
75        ///
76        /// - `invalid_permission`
77        ///
78        /// Access token is missing required scopes.
79        ///
80        /// Request an access token that includes the scopes required by this endpoint.
81        InvalidPermission,
82
83        /// 403 Forbidden.
84        ///
85        /// - `suspended_api_key`
86        ///
87        /// This API key is suspended
88        ///
89        /// [Contact support] if you believe this is a mistake.
90        ///
91        /// [Contact support]: https://resend.com/contact
92        SuspendedApiKey,
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        /// - `concurrent_idempotent_requests`
115        ///
116        /// Same idempotency key used while original request is still in progress.
117        ///
118        /// Try the request again later.
119        ConcurrentIdempotentRequests,
120
121        /// 409 Conflict
122        ///
123        /// - `invalid_idempotent_request`
124        ///
125        /// Same idempotency key used with a different request payload.
126        ///
127        /// Change your idempotency key or payload.
128        InvalidIdempotentRequest,
129
130        /// 409 Conflict
131        ///
132        /// - `resource_locked`
133        ///
134        /// Another request is already updating this resource.
135        ///
136        /// Retry the request after a short delay.
137        ResourceLocked,
138
139        /// 422 Unprocessable Content.
140        ///
141        /// - `invalid_attachment`
142        ///
143        /// Attachment must have either a `content` or `path`.
144        ///
145        /// Attachments must either have a `content` (strings, Buffer, or Stream contents) or
146        /// `path` to a remote resource (better for larger attachments).
147        InvalidAttachment,
148
149        /// 422 Unprocessable Content
150        ///
151        /// - `invalid_parameter`
152        ///
153        /// The parameter must be a valid UUID.
154        ///
155        /// Check the value and make sure it’s valid.
156        InvalidParameter,
157
158        /// 422 Unprocessable Content.
159        ///
160        /// - `missing_required_field`
161        ///
162        /// The request body is missing one or more required fields.
163        ///
164        /// Check the error message to see the list of missing fields.
165        MissingRequiredField,
166
167        /// 422 Unprocessable Content.
168        ///
169        /// - `missing_required_parameter`
170        ///
171        /// The request is missing one or more required parameters.
172        ///
173        /// Check the error message to see the list of missing parameters.
174        MissingRequiredParameter,
175
176        /// 429 Too Many Requests.
177        ///
178        /// - `daily_quota_exceeded`
179        ///
180        /// You have reached your daily email sending quota.
181        ///
182        /// Upgrade your plan to remove the daily quota limit or wait
183        /// until 24 hours have passed to continue sending.
184        DailyQuotaExceeded,
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        /// - `rate_limit_exceeded`
198        ///
199        /// Too many requests. Please limit the number of requests per second.
200        /// Or contact support to increase rate limit.
201        ///
202        /// You should read the response headers and reduce the rate at which you request the API.
203        /// This can be done by introducing a queue mechanism or reducing the number of concurrent
204        /// requests per second. If you have specific requirements, contact support to request a
205        /// rate increase.
206        ///
207        /// ## Note
208        ///
209        /// This should *never* be returned anymore as it's been replaced by the more detailed
210        /// [`Error::RateLimit`](crate::Error::RateLimit).
211        RateLimitExceeded,
212
213        /// 500 Internal Server Error
214        ///
215        /// - `application_error`
216        ///
217        /// An unexpected error occurred.
218        ///
219        /// Try the request again later. If the error does not resolve, check our status page
220        /// for service updates.
221        ApplicationError,
222
223        /// 500 Service Unavailable
224        ///
225        /// - `service_unavailable`
226        ///
227        /// API is temporarily unavailable
228        ///
229        /// Try the request again later. Check our [status page] for service updates.
230        ///
231        /// [status page]: https://resend-status.com/
232        ServiceUnavailable,
233    }
234
235    impl From<ErrorResponse> for ErrorKind {
236        fn from(value: ErrorResponse) -> Self {
237            Self::from(value.name)
238        }
239    }
240
241    impl<T: AsRef<str>> From<T> for ErrorKind {
242        fn from(value: T) -> Self {
243            match value.as_ref() {
244                "invalid_idempotency_key" => Self::InvalidIdempotencyKey,
245                "validation_error" => Self::ValidationError,
246                "missing_api_key" => Self::MissingApiKey,
247                "restricted_api_key" => Self::RestrictedApiKey,
248                "email_above_quota" => Self::EmailAboveQuota,
249                "invalid_permission" => Self::InvalidPermission,
250                "suspended_api_key" => Self::SuspendedApiKey,
251                "not_found" => Self::NotFound,
252                "method_not_allowed" => Self::MethodNotAllowed,
253                "concurrent_idempotent_requests" => Self::ConcurrentIdempotentRequests,
254                "invalid_idempotent_request" => Self::InvalidIdempotentRequest,
255                "resource_locked" => Self::ResourceLocked,
256                "invalid_attachment" => Self::InvalidAttachment,
257                "invalid_parameter" => Self::InvalidParameter,
258                "missing_required_field" => Self::MissingRequiredField,
259                "missing_required_parameter" => Self::MissingRequiredParameter,
260                "daily_quota_exceeded" => Self::DailyQuotaExceeded,
261                "monthly_quota_exceeded" => Self::MonthlyQuotaExceeded,
262                "rate_limit_exceeded" => Self::RateLimitExceeded,
263                "application_error" => Self::ApplicationError,
264                "service_unavailable" => Self::ServiceUnavailable,
265                _ => Self::Unrecognized,
266            }
267        }
268    }
269}
270
271#[cfg(test)]
272mod test {
273    /// This test parses [all Resend errors] and makes sure [`crate::types::ErrorKind`] models
274    /// them correctly, namely:
275    ///
276    /// - No error is parsed as [`crate::types::ErrorKind::Unrecognized`] (they are all recognized)
277    /// - The amount of errors from the website + 1 (for the unrecognized variant) is equal to the
278    ///   number of error variants in [`crate::types::ErrorKind`].
279    ///
280    /// There is a very real chance this will break in the future if anything changes in the
281    /// structure of the errors page but for now it is useful to have to make sure all errors are
282    /// modelled in the code.
283    ///
284    /// [all Resend errors]: https://resend.com/docs/api-reference/errors
285    #[allow(clippy::unwrap_used)]
286    #[tokio_shared_rt::test(shared = true)]
287    #[serial_test::serial]
288    #[cfg(not(feature = "blocking"))]
289    async fn errors_up_to_date() {
290        use std::collections::HashSet;
291
292        use strum::VariantNames;
293
294        use crate::types::{ErrorKind, ErrorResponse};
295
296        let response = reqwest::get("https://resend.com/docs/api-reference/errors")
297            .await
298            .unwrap();
299
300        let html = response.text().await.unwrap();
301
302        let fragment = scraper::Html::parse_document(&html);
303        let selector = scraper::Selector::parse("h3 > span").unwrap();
304
305        let re = regex::Regex::new(r"<code>(\w+)</code>").unwrap();
306
307        let expected = fragment
308            .select(&selector)
309            .map(|el| el.inner_html())
310            .filter(|el| el.starts_with("<code>"))
311            .flat_map(|inner| {
312                let mut results = vec![];
313                for (_, [error]) in re.captures_iter(&inner).map(|c| c.extract()) {
314                    results.push(error.to_string());
315                }
316                results
317            })
318            .collect::<HashSet<_>>();
319
320        let enum_names = ErrorKind::VARIANTS
321            .iter()
322            .filter(|&&name| name != "unrecognized") // IGNORE unrecognized
323            .map(|&name| name.to_string())
324            .collect::<HashSet<_>>();
325
326        // Make sure no error is parsed as `ErrorKind::Unrecognized`
327        for error_name in &expected {
328            let error_response = ErrorResponse {
329                status_code: 400,
330                message: String::new(),
331                name: error_name.clone(),
332            };
333
334            let error_kind = ErrorKind::from(error_response);
335            assert!(
336                !matches!(error_kind, ErrorKind::Unrecognized),
337                "Unrecognized: {error_name}"
338            );
339        }
340
341        // Print inconsistencies
342        let missing_from_enum = expected.difference(&enum_names).collect::<Vec<_>>();
343        let extra_in_enum = enum_names.difference(&expected).collect::<Vec<_>>();
344
345        if !missing_from_enum.is_empty() {
346            println!("On the page but missing from ErrorKind:");
347            for name in missing_from_enum {
348                println!("  - {name}");
349            }
350        }
351        if !extra_in_enum.is_empty() {
352            println!("In ErrorKind but not on the page:");
353            for name in extra_in_enum {
354                println!("  - {name}");
355            }
356        }
357
358        assert_eq!(expected.len(), enum_names.len());
359    }
360}