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}