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}