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}