1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
#[allow(unreachable_pub)]
pub mod types {
use serde::{Deserialize, Serialize};
/// Error returned as a response.
///
/// <https://resend.com/docs/api-reference/errors>
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
#[error("{name}: {message}")]
pub struct ErrorResponse {
#[serde(rename = "statusCode")]
pub status_code: u16,
pub message: String,
pub name: String,
}
impl ErrorResponse {
/// Returns the [`ErrorKind`].
#[must_use]
pub fn kind(&self) -> ErrorKind {
ErrorKind::from(self.name.as_str())
}
}
/// Error type for operations of a [`Resend`] client.
///
/// <https://resend.com/docs/api-reference/errors>
///
/// [`Resend`]: crate::Resend
#[non_exhaustive]
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(strum::EnumCount))]
pub enum ErrorKind {
/// Error name is not in the API spec.
Unrecognized,
/// 400 Bad Request.
///
/// - `invalid_idempotency_key`
///
/// The key must be between 1-256 chars.
///
/// Retry with a valid idempotency key.
InvalidIdempotencyKey,
/// 400 Bad Request.
///
/// - `validation_error`
///
/// We found an error with one or more fields in the request.
///
/// The message will contain more details about what field and error were found.
ValidationError400,
/// 401 Unauthorized.
///
/// - `missing_api_key`
///
/// Missing API key in the authorization header.
///
/// Include the following header `Authorization: Bearer YOUR_API_KEY` in the request.
MissingApiKey,
/// 401 Unauthorized
///
/// - `restricted_api_key`
///
/// This API key is restricted to only send emails.
///
/// Make sure the API key has `Full access` to perform actions other than sending emails.
RestrictedApiKey,
/// 403 Forbidden.
///
/// - `invalid_api_key`
///
/// API key is invalid.
///
/// Make sure the API key is correct or generate a new [API key in the dashboard].
///
/// [API key in the dashboard]: https://resend.com/api-keys
InvalidApiKey,
/// 403 Forbidden.
///
/// - `validation_error`
///
/// One of the following:
/// - <https://resend.com/docs/api-reference/errors#validation_error-2>
/// - <https://resend.com/docs/api-reference/errors#validation_error-3>
/// - <https://resend.com/docs/api-reference/errors#validation_error-4>
ValidationError403,
/// 404 Not Found.
///
/// - `not_found`
///
/// The requested endpoint does not exist.
///
/// Change your request URL to match a valid API endpoint.
NotFound,
/// 405 Method Not Allowed.
///
/// - `method_not_allowed`
///
/// Method is not allowed for the requested path.
///
/// Change your API endpoint to use a valid method.
MethodNotAllowed,
/// 409 Conflict
///
/// - `invalid_idempotent_request`
///
/// Same idempotency key used with a different request payload.
///
/// Change your idempotency key or payload.
InvalidIdempotentRequest,
/// 409 Conflict
///
/// - `concurrent_idempotent_requests`
///
/// Same idempotency key used while original request is still in progress.
///
/// Try the request again later.
ConcurrentIdempotentRequests,
/// 422 Unprocessable Content.
///
/// - `invalid_attachment`
///
/// Attachment must have either a `content` or `path`.
///
/// Attachments must either have a `content` (strings, Buffer, or Stream contents) or
/// `path` to a remote resource (better for larger attachments).
InvalidAttachment,
/// 422 Unprocessable Content.
///
/// - `invalid_from_address`
///
/// Invalid from field.
///
/// Make sure the from field is a valid. The email address needs to follow the
/// `email@example.com` or `Name <email@example.com>` format.
InvalidFromAddress,
/// 422 Unprocessable Content
///
/// - `invalid_access`
///
/// Access must be `"full_access" | "sending_access"`.
///
/// Make sure the API key has necessary permissions.
InvalidAccess,
/// 422 Unprocessable Content
///
/// - `invalid_parameter`
///
/// The parameter must be a valid UUID.
///
/// Check the value and make sure it’s valid.
InvalidParameter,
/// 422 Unprocessable Content
///
/// - `invalid_region`
///
/// Region must be `"us-east-1" | "us-east-1" | "sa-east-1"`.
///
/// Make sure the correct region is selected.
InvalidRegion,
/// 422 Unprocessable Content.
///
/// - `missing_required_field`
///
/// The request body is missing one or more required fields.
///
/// Check the error message to see the list of missing fields.
MissingRequiredField,
/// 429 Too Many Requests.
///
/// - `monthly_quota_exceeded`
///
/// You have reached your monthly email sending quota.
///
/// Upgrade your plan to remove the increase the monthly sending limit.
MonthlyQuotaExceeded,
/// 429 Too Many Requests.
///
/// - `daily_quota_exceeded`
///
/// You have reached your daily email sending quota.
///
/// Upgrade your plan to remove the daily quota limit or wait
/// until 24 hours have passed to continue sending.
DailyQuotaExceeded,
/// 429 Too Many Requests.
///
/// - `rate_limit_exceeded`
///
/// Too many requests. Please limit the number of requests per second.
/// Or contact support to increase rate limit.
///
/// You should read the response headers and reduce the rate at which you request the API.
/// This can be done by introducing a queue mechanism or reducing the number of concurrent
/// requests per second. If you have specific requirements, contact support to request a
/// rate increase.
///
/// ## Note
///
/// This should *never* be returned anymore as it's been replaced by the more detailed
/// [`Error::RateLimit`](crate::Error::RateLimit).
RateLimitExceeded,
/// 451 Unavailable For Legal Reasons
///
/// - `security_error`
///
/// We may have found a security issue with the request.
///
/// The message will contain more details. Contact support for more information.
SecurityError,
/// 500 Internal Server Error
///
/// - `application_error`
///
/// An unexpected error occurred.
///
/// Try the request again later. If the error does not resolve, check our status page
/// for service updates.
ApplicationError,
/// 500 Internal Server Error.
///
/// - `internal_server_error`
///
/// An unexpected error occurred.
///
/// Try the request again later. If the error does not resolve,
/// check our [`status page`] for service updates.
///
/// [`status page`]: https://resend-status.com/
InternalServerError,
}
impl From<ErrorResponse> for ErrorKind {
fn from(value: ErrorResponse) -> Self {
// There exist 2 validation_error variants, differentiate via status code
if value.name == "validation_error" {
return match value.status_code {
400 => Self::ValidationError400,
// This is a bit silly, since we have 2 validation errors with the same error
// code, we need to differentiate between them based on the message.
403 => Self::ValidationError403,
_ => Self::Unrecognized,
};
}
// For the rest use old From implementation.
Self::from(value.name)
}
}
impl<T: AsRef<str>> From<T> for ErrorKind {
fn from(value: T) -> Self {
match value.as_ref() {
"invalid_idempotency_key" => Self::InvalidIdempotencyKey,
"missing_api_key" => Self::MissingApiKey,
"restricted_api_key" => Self::RestrictedApiKey,
"invalid_api_key" => Self::InvalidApiKey,
"not_found" => Self::NotFound,
"method_not_allowed" => Self::MethodNotAllowed,
"invalid_idempotent_request" => Self::InvalidIdempotentRequest,
"concurrent_idempotent_requests" => Self::ConcurrentIdempotentRequests,
"invalid_attachment" => Self::InvalidAttachment,
"invalid_from_address" => Self::InvalidFromAddress,
"invalid_access" => Self::InvalidAccess,
"invalid_parameter" => Self::InvalidParameter,
"invalid_region" => Self::InvalidRegion,
"missing_required_field" => Self::MissingRequiredField,
"monthly_quota_exceeded" => Self::MonthlyQuotaExceeded,
"daily_quota_exceeded" => Self::DailyQuotaExceeded,
"rate_limit_exceeded" => Self::RateLimitExceeded,
"security_error" => Self::SecurityError,
"application_error" => Self::ApplicationError,
"internal_server_error" => Self::InternalServerError,
_ => Self::Unrecognized,
}
}
}
}
#[cfg(test)]
mod test {
/// This test parses [all Resend errors] and makes sure [`crate::types::ErrorKind`] models
/// them correctly, namely:
///
/// - No error is parsed as [`crate::types::ErrorKind::Unrecognized`] (they are all recognized)
/// - The amount of errors from the website + 1 (for the unrecognized variant) is equal to the
/// number of error variants in [`crate::types::ErrorKind`].
///
/// There is a very real chance this will break in the future if anything changes in the
/// structure of the errors page but for now it is useful to have to make sure all errors are
/// modelled in the code.
///
/// [all Resend errors]: https://resend.com/docs/api-reference/errors
#[allow(clippy::unwrap_used)]
#[tokio_shared_rt::test(shared = true)]
#[cfg(not(feature = "blocking"))]
async fn errors_up_to_date() {
use strum::EnumCount;
use crate::types::{ErrorKind, ErrorResponse};
let response = reqwest::get("https://resend.com/docs/api-reference/errors")
.await
.unwrap();
let html = response.text().await.unwrap();
let fragment = scraper::Html::parse_document(&html);
let selector = scraper::Selector::parse("h3 > span").unwrap();
let re = regex::Regex::new(r"<code>(\w+)</code>").unwrap();
let actual = ErrorKind::COUNT;
let expected = fragment
.select(&selector)
.map(|el| el.inner_html())
.map(|inner| {
let mut results = vec![];
for (_, [error]) in re.captures_iter(&inner).map(|c| c.extract()) {
results.push(error.to_string());
}
results
})
.collect::<Vec<_>>();
// Make sure no error is parsed as `ErrorKind::Unrecognized`
for error_name in expected.iter().flatten() {
let error_response = ErrorResponse {
status_code: 400,
message: String::new(),
name: error_name.clone(),
};
let error_kind = ErrorKind::from(error_response);
assert!(
!matches!(error_kind, ErrorKind::Unrecognized),
"Could not parse {error_name}"
);
}
// Expected is actually one less than what we have because of the `Unrecognized` variant.
let expected = expected.len() - 1;
assert_eq!(actual, expected);
}
}