An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1use serde::Serialize;
2use serde_json::Value;
3
4/// Error codes for the provisioning API.
5///
6/// Most variants serialize as SCREAMING_SNAKE_CASE. Exceptions use `#[serde(rename)]`
7/// when a specific wire format is required (e.g. `MethodNotImplemented` uses PascalCase
8/// to match the AT Protocol XRPC error format).
9///
10/// `#[non_exhaustive]` prevents external crates from writing exhaustive match
11/// arms — new variants can be added in future waves without breaking callers.
12#[non_exhaustive]
13#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
14#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
15pub enum ErrorCode {
16 InvalidClaim,
17 Unauthorized,
18 TokenExpired,
19 Forbidden,
20 NotFound,
21 WeakPassword,
22 RateLimited,
23 ExportInProgress,
24 ServiceUnavailable,
25 InternalError,
26 /// Returned for any XRPC NSID that has no registered handler.
27 ///
28 /// Serialized as `"MethodNotImplemented"` (PascalCase) to match the AT Protocol XRPC
29 /// error format, which uses PascalCase error names rather than SCREAMING_SNAKE_CASE.
30 #[serde(rename = "MethodNotImplemented")]
31 MethodNotImplemented,
32 /// An account with the given email already exists (pending or active).
33 AccountExists,
34 /// The requested handle is already claimed by an active or pending account.
35 HandleTaken,
36 /// The handle string failed basic format validation.
37 InvalidHandle,
38 /// A claim code that has already been redeemed is presented again.
39 /// Clients should inform the user to obtain a different code.
40 ClaimCodeRedeemed,
41 /// The DID has already been fully promoted to an active account.
42 DidAlreadyExists,
43 /// The external PLC directory returned a non-success response.
44 PlcDirectoryError,
45 /// A configured DNS provider returned an error when creating a subdomain record.
46 DnsError,
47 /// The requested handle does not resolve to a known DID locally or via DNS.
48 HandleNotFound,
49 /// Missing or absent Authorization header on a protected endpoint.
50 AuthenticationRequired,
51 /// Token is structurally invalid, has wrong signature, wrong audience, or DPoP mismatch.
52 InvalidToken,
53 /// A password-reset token has expired or has already been used.
54 ///
55 /// Serialized as `"ExpiredToken"` (PascalCase) to match the AT Protocol XRPC error format
56 /// for `com.atproto.server.resetPassword`.
57 #[serde(rename = "ExpiredToken")]
58 ExpiredToken,
59 // TODO: add remaining codes from Appendix A as endpoints are implemented:
60 // 400: INVALID_DOCUMENT, INVALID_PROOF, INVALID_ENDPOINT, INVALID_CONFIRMATION
61 // 401: INVALID_CREDENTIALS
62 // 403: TIER_RESTRICTED, DIDWEB_REQUIRES_DOMAIN, SINGLE_DEVICE_TIER
63 // 404: DEVICE_NOT_FOUND, DID_NOT_FOUND, NOT_IN_GRACE_PERIOD
64 // 409: ACCOUNT_NOT_FOUND, DEVICE_LIMIT, DID_EXISTS,
65 // ROTATION_IN_PROGRESS, LEASE_HELD, MIGRATION_IN_PROGRESS, ACTIVE_MIGRATION
66 // 410: ALREADY_DELETED
67 // 422: INVALID_KEY, KEY_MISMATCH, DIDWEB_SELF_SERVICE
68 // 423: ACCOUNT_LOCKED
69}
70
71impl ErrorCode {
72 /// Returns the canonical HTTP status code for this error as a `u16`.
73 pub fn status_code(&self) -> u16 {
74 match self {
75 ErrorCode::InvalidClaim => 400,
76 ErrorCode::Unauthorized => 401,
77 ErrorCode::TokenExpired => 401,
78 ErrorCode::Forbidden => 403,
79 ErrorCode::NotFound => 404,
80 ErrorCode::WeakPassword => 422,
81 ErrorCode::RateLimited => 429,
82 ErrorCode::ExportInProgress => 503,
83 ErrorCode::ServiceUnavailable => 503,
84 ErrorCode::InternalError => 500,
85 ErrorCode::MethodNotImplemented => 501,
86 ErrorCode::AccountExists => 409,
87 ErrorCode::HandleTaken => 409,
88 ErrorCode::InvalidHandle => 400,
89 ErrorCode::ClaimCodeRedeemed => 409,
90 ErrorCode::DidAlreadyExists => 409,
91 ErrorCode::PlcDirectoryError => 502,
92 ErrorCode::DnsError => 502,
93 ErrorCode::HandleNotFound => 404,
94 ErrorCode::AuthenticationRequired => 401,
95 ErrorCode::InvalidToken => 401,
96 ErrorCode::ExpiredToken => 400,
97 }
98 }
99}
100
101/// Provisioning API error, serialized as the standard error envelope.
102///
103/// Without details:
104/// ```json
105/// { "error": { "code": "NOT_FOUND", "message": "..." } }
106/// ```
107///
108/// With details:
109/// ```json
110/// { "error": { "code": "INVALID_CLAIM", "message": "...", "details": { "field": "email" } } }
111/// ```
112///
113/// Implements `IntoResponse` for Axum when the `axum` feature is enabled.
114#[derive(Debug, Serialize, thiserror::Error)]
115#[error("{code:?}: {message}")]
116pub struct ApiError {
117 code: ErrorCode,
118 message: String,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 details: Option<Value>,
121}
122
123impl ApiError {
124 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
125 Self {
126 code,
127 message: message.into(),
128 details: None,
129 }
130 }
131
132 pub fn with_details(mut self, details: Value) -> Self {
133 self.details = Some(details);
134 self
135 }
136
137 /// Returns the HTTP status code for this error as a `u16`.
138 pub fn status_code(&self) -> u16 {
139 self.code.status_code()
140 }
141}
142
143/// Wraps `ApiError` in the `{ "error": ... }` envelope for serialization.
144#[cfg(any(feature = "axum", test))]
145#[derive(Serialize)]
146struct ApiErrorEnvelope {
147 error: ApiError,
148}
149
150#[cfg(feature = "axum")]
151mod axum_integration {
152 use super::*;
153 use axum::{
154 http::{header, StatusCode},
155 response::{IntoResponse, Response},
156 Json,
157 };
158
159 impl IntoResponse for ApiError {
160 fn into_response(self) -> Response {
161 let status = StatusCode::from_u16(self.code.status_code())
162 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
163
164 match serde_json::to_vec(&ApiErrorEnvelope { error: self }) {
165 Ok(body) => {
166 (status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
167 }
168 Err(err) => {
169 tracing::error!(error = %err, "failed to serialize ApiError");
170 (
171 StatusCode::INTERNAL_SERVER_ERROR,
172 Json(serde_json::json!({
173 "error": {
174 "code": "INTERNAL_SERVER_ERROR",
175 "message": "internal error"
176 }
177 })),
178 )
179 .into_response()
180 }
181 }
182 }
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use serde_json::json;
190
191 #[test]
192 fn serializes_to_error_envelope() {
193 let err = ApiError::new(ErrorCode::NotFound, "resource not found");
194 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap();
195 assert_eq!(
196 actual,
197 json!({
198 "error": {
199 "code": "NOT_FOUND",
200 "message": "resource not found"
201 }
202 })
203 );
204 }
205
206 #[test]
207 fn serializes_with_details() {
208 let err = ApiError::new(ErrorCode::InvalidClaim, "validation failed")
209 .with_details(json!({ "field": "email" }));
210 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap();
211 assert_eq!(
212 actual,
213 json!({
214 "error": {
215 "code": "INVALID_CLAIM",
216 "message": "validation failed",
217 "details": { "field": "email" }
218 }
219 })
220 );
221 }
222
223 #[test]
224 fn expired_token_serializes_as_pascal_case() {
225 let err = ApiError::new(ErrorCode::ExpiredToken, "token has expired");
226 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap();
227 assert_eq!(actual["error"]["code"], "ExpiredToken");
228 }
229
230 #[test]
231 fn omits_details_when_absent() {
232 let err = ApiError::new(ErrorCode::Forbidden, "access denied");
233 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap();
234 assert!(!actual["error"].as_object().unwrap().contains_key("details"));
235 }
236
237 #[test]
238 fn status_code_mapping() {
239 let cases = [
240 (ErrorCode::InvalidClaim, 400u16),
241 (ErrorCode::Unauthorized, 401),
242 (ErrorCode::TokenExpired, 401),
243 (ErrorCode::Forbidden, 403),
244 (ErrorCode::NotFound, 404),
245 (ErrorCode::WeakPassword, 422),
246 (ErrorCode::RateLimited, 429),
247 (ErrorCode::ExportInProgress, 503),
248 (ErrorCode::ServiceUnavailable, 503),
249 (ErrorCode::InternalError, 500),
250 (ErrorCode::MethodNotImplemented, 501),
251 (ErrorCode::AccountExists, 409),
252 (ErrorCode::HandleTaken, 409),
253 (ErrorCode::InvalidHandle, 400),
254 (ErrorCode::ClaimCodeRedeemed, 409),
255 (ErrorCode::DidAlreadyExists, 409),
256 (ErrorCode::PlcDirectoryError, 502),
257 (ErrorCode::DnsError, 502),
258 (ErrorCode::HandleNotFound, 404),
259 (ErrorCode::AuthenticationRequired, 401),
260 (ErrorCode::InvalidToken, 401),
261 (ErrorCode::ExpiredToken, 400),
262 ];
263 for (code, expected) in cases {
264 assert_eq!(code.status_code(), expected, "wrong status for {code:?}");
265 }
266 }
267
268 #[cfg(feature = "axum")]
269 mod axum_tests {
270 use super::*;
271 use axum::http::StatusCode;
272 use axum::response::IntoResponse;
273
274 #[tokio::test]
275 async fn into_response_correct_status_and_body() {
276 let err = ApiError::new(ErrorCode::NotFound, "not found");
277 let response = err.into_response();
278 assert_eq!(response.status(), StatusCode::NOT_FOUND);
279 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
280 .await
281 .unwrap();
282 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
283 assert_eq!(json["error"]["code"], "NOT_FOUND");
284 assert_eq!(json["error"]["message"], "not found");
285 }
286 }
287}