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
//! Error types for the `discord-user-rs` library
use thiserror::Error;
/// Result type alias for discord operations
pub type Result<T> = std::result::Result<T, DiscordError>;
/// Redact the URL path from a `reqwest::Error`'s Display output.
///
/// `reqwest::Error::Display` embeds the full request URL by default. Several
/// Discord routes (`ExecuteWebhook`, `EditWebhookWithToken`,
/// `DeleteWebhookWithToken`, interaction callbacks) include a webhook token
/// inside the URL path — leaking that token via stderr / log files would
/// expose a credential that lets anyone post as the webhook. We rewrite the
/// URL down to its origin (`scheme://host`) before display.
fn redact_reqwest_err(e: &reqwest::Error) -> String {
let mut s = e.to_string();
if let Some(url) = e.url() {
let raw = url.as_str();
let host = url.host_str().unwrap_or("");
let origin = format!("{}://{}", url.scheme(), host);
if !raw.is_empty() && raw != origin {
s = s.replace(raw, &origin);
}
}
s
}
/// Errors that can occur during Discord operations
#[derive(Error, Debug)]
pub enum DiscordError {
/// WebSocket connection error
#[error("WebSocket error: {0}")]
WebSocket(Box<tokio_tungstenite::tungstenite::Error>),
/// HTTP request error. Display redacts URL paths to avoid leaking webhook
/// tokens (which Discord embeds in `ExecuteWebhook` / `*WithToken` routes).
#[error("HTTP error: {}", redact_reqwest_err(.0))]
Http(#[from] reqwest::Error),
/// JSON serialization/deserialization error
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
/// Rate limited by Discord
#[error("Rate limited, retry after {retry_after}s (global: {global}, bucket: {bucket:?}, scope: {scope:?})")]
RateLimited { retry_after: f64, bucket: Option<String>, global: bool, scope: Option<String> },
/// Account verification required
#[error("Account verification required")]
VerificationRequired,
/// Captcha required
#[error("Captcha required: {service}")]
CaptchaRequired { service: String },
/// Gateway connection failed
#[error("Gateway connection failed: {0}")]
GatewayConnection(String),
/// Authentication failed
#[error("Authentication failed")]
AuthenticationFailed,
/// WebSocket not initialized
#[error("WebSocket not initialized, call init() first")]
NotInitialized,
/// Request timeout
#[error("Request timed out")]
Timeout,
/// Invalid or expired token
#[error("Invalid token")]
InvalidToken,
/// Resource not found
#[error("{resource_type} not found: {id}")]
NotFound { resource_type: String, id: String },
/// Permission denied
#[error("Missing permission: {permission}")]
PermissionDenied { permission: String },
/// Invalid request parameters
#[error("Invalid request: {0}")]
InvalidRequest(String),
/// Maximum retries exceeded
#[error("Maximum retries exceeded")]
MaxRetriesExceeded,
/// Cloudflare invalid-request budget threshold reached.
///
/// Discord's CDN tracks invalid responses (HTTP 401/403/429) per IP and
/// applies a temporary IP ban (Cloudflare error 1015) when the count
/// exceeds 10,000 within a rolling 10-minute window. Callers that opt in
/// to fail-fast behaviour can observe this variant before the ban hits.
#[error("invalid-request budget threshold reached: {count} invalid responses in the last 10 minutes (Cloudflare 1015 risk)")]
InvalidRequestThresholdReached { count: usize },
/// Gateway requested a reconnection (opcode 7)
#[error("Gateway requested reconnection")]
GatewayReconnectRequested,
/// An HTTP response with a non-2xx status that doesn't map to a more
/// specific variant. Preserves the raw status code and response body for
/// inspection by callers.
#[error("HTTP {status}: {body}")]
UnexpectedStatusCode { status: u16, body: String },
/// Discord's API returned a 5xx status indicating a server-side error.
#[error("Discord service error ({status}): {body}")]
ServiceError { status: u16, body: String },
/// Model-level validation error — caught before any HTTP request is made.
///
/// Returned by request structs / operation methods when the provided data
/// violates a known Discord constraint (e.g. message too long, too many
/// embeds). No network request is ever sent when this variant is returned.
#[error("Model validation error: {0}")]
Model(ModelError),
/// Generic error (use specific variants when possible)
#[error("{0}")]
Other(String),
/// Error with context
#[error("{context}")]
Context {
context: String,
#[source]
source: Box<DiscordError>,
},
}
/// Specific model-level constraint violations.
///
/// Each variant maps to a Discord API constraint that can be checked locally
/// without making a network request.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum ModelError {
/// Message content exceeds the 2000-character limit.
#[error("message content is too long: {0} characters (max 2000)")]
MessageTooLong(usize),
/// More than 10 embeds were supplied in a single message.
#[error("too many embeds: {0} (max 10)")]
EmbedAmount(usize),
/// An embed's total character count exceeds 6000.
#[error("embed is too large: {0} characters (max 6000)")]
EmbedTooLarge(usize),
/// More than 3 stickers were attached to a single message.
#[error("too many stickers: {0} (max 3)")]
StickerAmount(usize),
/// Bulk-delete requires between 2 and 100 message IDs.
#[error("invalid bulk-delete count: {0} (must be 2-100)")]
BulkDeleteAmount(usize),
/// A name is shorter than the minimum allowed length.
#[error("name too short: {0} characters (min {1})")]
NameTooShort(usize, usize),
/// A name exceeds the maximum allowed length.
#[error("name too long: {0} characters (max {1})")]
NameTooLong(usize, usize),
/// The member lacks a required permission.
#[error("invalid permissions: required {required:#b}, present {present:#b}")]
InvalidPermissions { required: u64, present: u64 },
/// A role or channel hierarchy constraint was violated.
#[error("hierarchy constraint violated")]
Hierarchy,
/// Cannot send messages to a bot account.
#[error("cannot send messages to a bot")]
MessagingBot,
/// The channel type is incompatible with the requested operation.
#[error("invalid channel type for this operation")]
InvalidChannelType,
/// Guild name must be 2-100 characters.
#[error("guild name must be 2-100 characters (got {0})")]
GuildNameLength(usize),
/// Channel topic must be 0-1024 characters.
#[error("channel topic must be 0-1024 characters (got {0})")]
ChannelTopicLength(usize),
/// Role name must be 1-100 characters.
#[error("role name must be 1-100 characters (got {0})")]
RoleNameLength(usize),
/// Webhook name must be 1-80 characters.
#[error("webhook name must be 1-80 characters (got {0})")]
WebhookNameLength(usize),
/// Invite max_age must be 0-604800 seconds.
#[error("invite max_age must be 0-604800 seconds (got {0})")]
InviteMaxAge(u32),
/// Invite max_uses must be 0-100.
#[error("invite max_uses must be 0-100 (got {0})")]
InviteMaxUses(u32),
}
impl From<tokio_tungstenite::tungstenite::Error> for DiscordError {
fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
DiscordError::WebSocket(Box::new(err))
}
}
impl From<ModelError> for DiscordError {
fn from(e: ModelError) -> Self {
DiscordError::Model(e)
}
}
impl DiscordError {
/// Check if the error is retryable.
///
/// This returns true for:
/// - Rate limits
/// - Timeouts
/// - Connection errors
/// - Gateway reconnection requests
/// - Temporary HTTP errors (5xx, timeouts)
pub fn is_retryable(&self) -> bool {
match self {
// Rate limits are temporary
Self::RateLimited { .. } => true,
// Timeouts are temporary
Self::Timeout => true,
// Gateway requested reconnection
Self::GatewayReconnectRequested => true,
// Connection failures are usually retryable
Self::GatewayConnection(_) => true,
Self::WebSocket(_) => true,
// HTTP errors depend on the status
Self::Http(e) => {
if e.is_timeout() || e.is_connect() {
return true;
}
if let Some(status) = e.status() {
// 5xx errors are server errors and might be temporary
if status.is_server_error() {
return true;
}
// 429 is handled by RateLimited, but check just in case
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
return true;
}
}
false
}
// Recursively check source for Context errors
Self::Context { source, .. } => source.is_retryable(),
// 5xx service errors are retryable
Self::ServiceError { .. } => true,
// Auth errors, invalid data, permissions, etc. are not retryable
Self::VerificationRequired | Self::CaptchaRequired { .. } | Self::AuthenticationFailed | Self::NotInitialized | Self::InvalidToken | Self::NotFound { .. } | Self::PermissionDenied { .. } | Self::InvalidRequest(_) | Self::MaxRetriesExceeded | Self::Json(_) | Self::Other(_) | Self::Model(_) | Self::UnexpectedStatusCode { .. } | Self::InvalidRequestThresholdReached { .. } => false,
}
}
}
/// Trait for adding context to results
pub trait WithContext<T> {
fn context<C>(self, context: C) -> Result<T>
where
C: std::fmt::Display + Send + Sync + 'static;
fn with_context<C, F>(self, f: F) -> Result<T>
where
C: std::fmt::Display + Send + Sync + 'static,
F: FnOnce() -> C;
}
impl<T, E> WithContext<T> for std::result::Result<T, E>
where
E: Into<DiscordError>,
{
fn context<C>(self, context: C) -> Result<T>
where
C: std::fmt::Display + Send + Sync + 'static,
{
self.map_err(|e| DiscordError::Context { context: context.to_string(), source: Box::new(e.into()) })
}
fn with_context<C, F>(self, f: F) -> Result<T>
where
C: std::fmt::Display + Send + Sync + 'static,
F: FnOnce() -> C,
{
self.map_err(|e| DiscordError::Context { context: f().to_string(), source: Box::new(e.into()) })
}
}