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
#[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, strum::VariantNames),
strum(serialize_all = "snake_case")
)]
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,
ValidationError,
/// 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,
RestrictedApiKey,
/// 403 Forbidden.
///
/// - `email_above_quota`
///
/// You can’t retrieve this email’s content because it was above quota when received.
///
/// [Upgrade your plan] to increase your quota.
///
/// [Upgrade your plan]: https://resend.com/settings/billing
EmailAboveQuota,
/// 403 Forbidden.
///
/// - `invalid_permission`
///
/// Access token is missing required scopes.
///
/// Request an access token that includes the scopes required by this endpoint.
InvalidPermission,
/// 403 Forbidden.
///
/// - `suspended_api_key`
///
/// This API key is suspended
///
/// [Contact support] if you believe this is a mistake.
///
/// [Contact support]: https://resend.com/contact
SuspendedApiKey,
/// 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
///
/// - `concurrent_idempotent_requests`
///
/// Same idempotency key used while original request is still in progress.
///
/// Try the request again later.
ConcurrentIdempotentRequests,
/// 409 Conflict
///
/// - `invalid_idempotent_request`
///
/// Same idempotency key used with a different request payload.
///
/// Change your idempotency key or payload.
InvalidIdempotentRequest,
/// 409 Conflict
///
/// - `resource_locked`
///
/// Another request is already updating this resource.
///
/// Retry the request after a short delay.
ResourceLocked,
/// 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_parameter`
///
/// The parameter must be a valid UUID.
///
/// Check the value and make sure it’s valid.
InvalidParameter,
/// 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,
/// 422 Unprocessable Content.
///
/// - `missing_required_parameter`
///
/// The request is missing one or more required parameters.
///
/// Check the error message to see the list of missing parameters.
MissingRequiredParameter,
/// 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.
///
/// - `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.
///
/// - `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,
/// 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 Service Unavailable
///
/// - `service_unavailable`
///
/// API is temporarily unavailable
///
/// Try the request again later. Check our [status page] for service updates.
///
/// [status page]: https://resend-status.com/
ServiceUnavailable,
}
impl From<ErrorResponse> for ErrorKind {
fn from(value: ErrorResponse) -> Self {
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,
"validation_error" => Self::ValidationError,
"missing_api_key" => Self::MissingApiKey,
"restricted_api_key" => Self::RestrictedApiKey,
"email_above_quota" => Self::EmailAboveQuota,
"invalid_permission" => Self::InvalidPermission,
"suspended_api_key" => Self::SuspendedApiKey,
"not_found" => Self::NotFound,
"method_not_allowed" => Self::MethodNotAllowed,
"concurrent_idempotent_requests" => Self::ConcurrentIdempotentRequests,
"invalid_idempotent_request" => Self::InvalidIdempotentRequest,
"resource_locked" => Self::ResourceLocked,
"invalid_attachment" => Self::InvalidAttachment,
"invalid_parameter" => Self::InvalidParameter,
"missing_required_field" => Self::MissingRequiredField,
"missing_required_parameter" => Self::MissingRequiredParameter,
"daily_quota_exceeded" => Self::DailyQuotaExceeded,
"monthly_quota_exceeded" => Self::MonthlyQuotaExceeded,
"rate_limit_exceeded" => Self::RateLimitExceeded,
"application_error" => Self::ApplicationError,
"service_unavailable" => Self::ServiceUnavailable,
_ => 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)]
#[serial_test::serial]
#[cfg(not(feature = "blocking"))]
async fn errors_up_to_date() {
use std::collections::HashSet;
use strum::VariantNames;
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 expected = fragment
.select(&selector)
.map(|el| el.inner_html())
.filter(|el| el.starts_with("<code>"))
.flat_map(|inner| {
let mut results = vec![];
for (_, [error]) in re.captures_iter(&inner).map(|c| c.extract()) {
results.push(error.to_string());
}
results
})
.collect::<HashSet<_>>();
let enum_names = ErrorKind::VARIANTS
.iter()
.filter(|&&name| name != "unrecognized") // IGNORE unrecognized
.map(|&name| name.to_string())
.collect::<HashSet<_>>();
// Make sure no error is parsed as `ErrorKind::Unrecognized`
for error_name in &expected {
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),
"Unrecognized: {error_name}"
);
}
// Print inconsistencies
let missing_from_enum = expected.difference(&enum_names).collect::<Vec<_>>();
let extra_in_enum = enum_names.difference(&expected).collect::<Vec<_>>();
if !missing_from_enum.is_empty() {
println!("On the page but missing from ErrorKind:");
for name in missing_from_enum {
println!(" - {name}");
}
}
if !extra_in_enum.is_empty() {
println!("In ErrorKind but not on the page:");
for name in extra_in_enum {
println!(" - {name}");
}
}
assert_eq!(expected.len(), enum_names.len());
}
}