didbot_serve/error.rs
1//! The error body every failing route returns.
2
3use axum::http::{HeaderName, HeaderValue, StatusCode};
4use axum::response::{IntoResponse, Response};
5use axum::Json;
6use didbot_pds::{EstopCause, Halted, ProvisionError, RecordError};
7use serde::{Deserialize, Serialize};
8
9/// An atproto XRPC error body: a machine-readable name and a human sentence.
10///
11/// atproto clients switch on `error` and only show `message`, so the name is
12/// the stable part. Renaming one is a wire break even though nothing here
13/// checks it.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ErrorBody {
16 /// Machine-readable error name, e.g. `AttestationFailed`.
17 pub error: String,
18 /// One sentence for a human reading a terminal.
19 pub message: String,
20}
21
22/// A failure rendered as an HTTP status plus an [`ErrorBody`].
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ApiError {
25 status: StatusCode,
26 body: ErrorBody,
27 /// Extra response headers, such as `WWW-Authenticate` or `DPoP-Nonce`.
28 ///
29 /// A client's next move after `AuthenticationRequired`, `ExpiredToken`
30 /// and `InvalidToken` differs — ask for a credential, refresh, or give
31 /// up — and a header is how it learns which without parsing the message.
32 /// Empty for almost every error this crate raises, which is why it is a
33 /// field rather than a second constructor for every variant above.
34 headers: Vec<(HeaderName, HeaderValue)>,
35}
36
37impl ApiError {
38 /// Builds an error response from its parts.
39 pub fn new(status: StatusCode, error: impl Into<String>, message: impl Into<String>) -> Self {
40 Self {
41 status,
42 body: ErrorBody {
43 error: error.into(),
44 message: message.into(),
45 },
46 headers: Vec::new(),
47 }
48 }
49
50 /// Attaches a response header, such as `WWW-Authenticate`.
51 #[must_use]
52 pub fn with_header(mut self, name: HeaderName, value: impl Into<String>) -> Self {
53 if let Ok(value) = HeaderValue::from_str(&value.into()) {
54 self.headers.push((name, value));
55 }
56 self
57 }
58
59 /// A 400 for a body that did not parse or did not make sense.
60 pub fn bad_request(message: impl Into<String>) -> Self {
61 Self::new(StatusCode::BAD_REQUEST, "InvalidRequest", message)
62 }
63
64 /// No repository here, under either a DID or a handle.
65 ///
66 /// `RepoNotFound` is spelled as atproto's own personal data server spells
67 /// it. The `com.atproto.repo.*` lexicons declare no error for it — only
68 /// `RecordNotFound` and `InvalidSwap` — but every `com.atproto.sync.*`
69 /// lexicon that can miss a repository declares this name, and the
70 /// reference implementation raises it from `describeRepo`,
71 /// `deleteRecord` and `putRecord` alike. Inventing a second spelling for
72 /// the same condition is the failure this constructor exists to avoid.
73 ///
74 /// 404 rather than the reference implementation's 400. XRPC clients
75 /// switch on the name and not the status, the name is the one they know,
76 /// and "not found" is what the status field is for.
77 pub fn repo_not_found(repo: &str) -> Self {
78 Self::new(
79 StatusCode::NOT_FOUND,
80 "RepoNotFound",
81 format!("could not find a repository for `{repo}`"),
82 )
83 }
84
85 /// The authenticated agent is not the one the repository belongs to.
86 ///
87 /// `RepoMismatch`, its own name rather than a reused one: an agent
88 /// token is real and unexpired — [`ApiError::new`] with
89 /// `AuthenticationRequired`, `ExpiredToken` or `InvalidToken` is what a
90 /// route answers before this is ever reached — and the caller needs to
91 /// know that what failed is *whose* repository this is, which no retry
92 /// of the same credential against the same repository can fix.
93 /// `plan/auth-types.md` treats "an agent never writes another agent's
94 /// repository" as an invariant rather than a policy knob, and this is
95 /// where it is enforced: `routes::write_record` and its siblings compare
96 /// the DID `auth::require_agent_token` resolved against the repository
97 /// the request names, before any write reaches the registry.
98 pub fn repo_mismatch(authenticated: &str, repo: &str) -> Self {
99 Self::new(
100 StatusCode::FORBIDDEN,
101 "RepoMismatch",
102 format!(
103 "`{authenticated}` may not write to `{repo}`'s repository; an agent's \
104 credential only authorizes writes to its own"
105 ),
106 )
107 }
108
109 /// The authenticated agent is not the account this admin route names.
110 ///
111 /// `AccountMismatch`, its own name for the same reason [`Self::repo_mismatch`]
112 /// has one: an agent token is real and unexpired, and what failed is
113 /// *whose* account this is. Every account-lifecycle mutation is
114 /// self-service — an agent's own credential, checked against the account
115 /// the request names, is the whole of what authorizes them; see
116 /// `plan/auth-types.md`. This is what that credential gets for naming
117 /// somebody else's account.
118 pub fn account_mismatch(authenticated: &str, target: &str) -> Self {
119 Self::new(
120 StatusCode::FORBIDDEN,
121 "AccountMismatch",
122 format!(
123 "`{authenticated}` may not act on `{target}`'s account; an agent's own \
124 credential only authorizes acting on itself"
125 ),
126 )
127 }
128
129 /// A caller has exceeded a rate limit; see `crate::rate_limit`.
130 ///
131 /// `RateLimitExceeded` names no error the lexicons declare — the same
132 /// position `MethodNotImplemented`'s own doc note (`routes::unknown_route`)
133 /// describes for a route that does not exist at all. 429 is the standard
134 /// HTTP status for this and every client is expected to already know it
135 /// even when the name is new to it.
136 pub fn rate_limited(message: impl Into<String>) -> Self {
137 Self::new(StatusCode::TOO_MANY_REQUESTS, "RateLimitExceeded", message)
138 }
139
140 /// This deployment already holds as many accounts as its operator has
141 /// configured room for.
142 ///
143 /// Its own name rather than `Halted`: that name says a stop was thrown,
144 /// by an operator or by this server when its operator's record is
145 /// missing, and neither is true of a full deployment — an account
146 /// leaving, or an operator raising the cap, both clear this without
147 /// anyone having pulled anything. 503, the same posture
148 /// `ProvisionError::WriteQueueFull` takes: a statement about the
149 /// deployment, not about the caller.
150 pub fn account_cap_reached(cap: u64) -> Self {
151 Self::new(
152 StatusCode::SERVICE_UNAVAILABLE,
153 "AccountCapReached",
154 format!("this deployment already holds its configured maximum of {cap} accounts"),
155 )
156 }
157
158 /// This deployment has not finished being born, or has lost its public
159 /// standing, and the route asked for is one it therefore defers.
160 ///
161 /// `503` with `Retry-After`, and an error name — `ServerNotReady` — no
162 /// lexicon declares, the same position `RateLimitExceeded` above is in.
163 /// The status and the header are what a caller actually reads, and both
164 /// are standard.
165 ///
166 /// # What a relay is meant to conclude
167 ///
168 /// Three conditions a crawler has to tell apart, and this is exactly
169 /// one of them:
170 ///
171 /// - **Try later** — this. `503`, a `Retry-After`, a named reason. The
172 /// server is fine and is not finished. Retry on the hint.
173 /// - **Broken** — a refused connection, a `502` from something in
174 /// front, or a `500` with no `Retry-After`. Nothing emits a `500` for
175 /// this condition, which is what makes the absence of the header
176 /// meaningful.
177 /// - **Nothing here** — `200` with an empty list or stream. Fine, and
178 /// holds nothing. Nothing to retry.
179 ///
180 /// Never a `404`: "this method does not exist" and "this method is not
181 /// ready" are facts a relay reacts to completely differently, and only
182 /// one of them is worth ever asking about again.
183 ///
184 /// The message names the state, which for an `unclaimed` server is the
185 /// whole answer: nobody is currently answerable for this deployment, and
186 /// the fix is to run `didbot operate`.
187 pub fn not_ready(route: &str, snapshot: &didbot_pds::LifecycleSnapshot) -> Self {
188 Self::new(
189 StatusCode::SERVICE_UNAVAILABLE,
190 "ServerNotReady",
191 format!(
192 "this deployment is `{}` and does not serve `{route}`; retry after {}s",
193 snapshot.state,
194 didbot_pds::RETRY_AFTER_SECONDS,
195 ),
196 )
197 .with_header(
198 axum::http::header::RETRY_AFTER,
199 didbot_pds::RETRY_AFTER_SECONDS.to_string(),
200 )
201 }
202
203 /// The repository is here and the record is not.
204 ///
205 /// Declared by `com.atproto.repo.getRecord`, which is the one route that
206 /// returns it: `deleteRecord` is specified to succeed when the record was
207 /// already absent.
208 pub fn record_not_found(uri: &str) -> Self {
209 Self::new(
210 StatusCode::NOT_FOUND,
211 "RecordNotFound",
212 format!("could not locate record: {uri}"),
213 )
214 }
215
216 /// The status this error responds with.
217 pub fn status(&self) -> StatusCode {
218 self.status
219 }
220
221 /// The machine-readable error name.
222 pub fn error(&self) -> &str {
223 &self.body.error
224 }
225
226 /// The human-readable message.
227 pub fn message(&self) -> &str {
228 &self.body.message
229 }
230}
231
232impl From<&ProvisionError> for ApiError {
233 /// Maps a provisioning failure onto a status and an error name.
234 ///
235 /// The `Display` text of the underlying error becomes the message. Every
236 /// error in the chain is written for the caller to read: a secret, a
237 /// signing key and the path to a file this deployment keeps are each kept
238 /// out of it, and a store refusal carries only the sizes and the sentence
239 /// that are about the request itself.
240 fn from(err: &ProvisionError) -> Self {
241 let (status, name) = match err {
242 ProvisionError::Identity(..) => (StatusCode::BAD_REQUEST, "InvalidIdentity"),
243 // Unreachable against a correct mint; if it ever fires, it is
244 // this server's minting logic at fault, not the caller's request.
245 ProvisionError::MintEscapesZone { .. } => {
246 (StatusCode::INTERNAL_SERVER_ERROR, "MintEscapesZone")
247 }
248 ProvisionError::NameIsZone { .. } => (StatusCode::BAD_REQUEST, "InvalidName"),
249 ProvisionError::NameOutsidePool { .. } => (StatusCode::BAD_REQUEST, "NameOutsidePool"),
250 ProvisionError::UnknownPool { .. } => (StatusCode::BAD_REQUEST, "UnknownPool"),
251 ProvisionError::InvalidFragment { .. } => (StatusCode::BAD_REQUEST, "InvalidFragment"),
252 ProvisionError::LabelTaken { .. } => (StatusCode::CONFLICT, "NameTaken"),
253 ProvisionError::PolicyRefused { .. } => (StatusCode::FORBIDDEN, "PolicyRefused"),
254 // Its own name rather than InvalidIdentity: the handle and the
255 // agent id are two fields of the request, and a caller told only
256 // "invalid identity" would go and check the wrong one.
257 ProvisionError::Handle(..) => (StatusCode::BAD_REQUEST, "InvalidHandle"),
258 ProvisionError::HandleEscapesZone { .. } => {
259 (StatusCode::BAD_REQUEST, "HandleEscapesZone")
260 }
261 // 409, not 400: the request is well-formed and would have been
262 // accepted a moment before somebody else took the name.
263 ProvisionError::HandleTaken { .. } => (StatusCode::CONFLICT, "HandleTaken"),
264 ProvisionError::UnknownAccount { .. } => (StatusCode::NOT_FOUND, "UnknownAccount"),
265 // Only reachable from a route outside `com.atproto.sync.*`, which
266 // no route currently is: the sync routes name this
267 // `BlockNotFound`, which is what their lexicons declare. See
268 // `routes::sync_error`.
269 ProvisionError::BlockNotFound { .. } => (StatusCode::BAD_REQUEST, "BlockNotFound"),
270 // 403: the account exists and the caller may know that, and the
271 // refusal is about which account it is rather than about the
272 // request. Nothing a caller can change makes this one succeed.
273 ProvisionError::ServerAccount { .. } => (StatusCode::FORBIDDEN, "ServerAccount"),
274 // Naming is configuration, and the caller supplies at most a hint
275 // that is dropped when it cannot be honoured. Every way this
276 // fails -- a namer that will not answer, a pool with nothing left
277 // in it -- is the deployment's to fix and not the caller's.
278 ProvisionError::Name(..) => (StatusCode::INTERNAL_SERVER_ERROR, "NamingFailed"),
279 // DNS and the store are this server's own dependencies. A caller
280 // cannot fix either, so they are not the caller's fault.
281 // 507, and its own name, before the general store arm below it.
282 // A deployment that has run out of room is not broken and is not
283 // the caller's fault either: the request was well formed, nothing
284 // was written, and the same request works again once there is
285 // room. `StorageFull` is what tells a client to stop writing and
286 // come back rather than retry immediately or give up, which is
287 // the distinction `plan/store-scale.md` calls "refuse rather than
288 // exhaust". A 500 says none of that.
289 ProvisionError::Store(didbot_pds::StoreError::Full { .. })
290 | ProvisionError::Record(RecordError::StorageFull { .. }) => {
291 (StatusCode::INSUFFICIENT_STORAGE, "StorageFull")
292 }
293 // 409, and its own name, before the general store arm below it.
294 // A DID this deployment already holds is the one store refusal
295 // that is not a store failure: nothing is unwell, nothing was
296 // written, and the request will be refused the same way forever
297 // because `insert` is not an upsert (see
298 // [`didbot_pds::StoreError::AlreadyExists`]). A 500 tells a
299 // caller to back off and retry, which is exactly the wrong move
300 // -- it is the same posture `StorageFull` is carved out of this
301 // arm for, and the same 409 `HandleTaken` and
302 // `InvalidAccountTransition` report for a well-formed request
303 // that collides with state already here.
304 //
305 // `AccountAlreadyExists` names no error the lexicons declare,
306 // the same position `RateLimitExceeded` and `ServerNotReady` are
307 // in: no `com.atproto.*` lexicon has a name for this because
308 // upstream mints a DID nobody asked for rather than one derived
309 // from a caller's `accountId`, so the collision cannot arise there.
310 // `DuplicateCreate` is declared, but only by
311 // `com.atproto.server.getAccountInviteCodes` and only about
312 // invite codes; reusing it here would tell a client something
313 // false. 409 is the standard status and every client already
314 // knows it, and the name is there for the ones that switch on it.
315 ProvisionError::Store(didbot_pds::StoreError::AlreadyExists { .. }) => {
316 (StatusCode::CONFLICT, "AccountAlreadyExists")
317 }
318 // 400: the identity is malformed, whatever else is here.
319 ProvisionError::OidcClaimRepeated { .. } => (StatusCode::BAD_REQUEST, "InvalidRequest"),
320 // 403: the store's own refusal of a held account, the same
321 // answer `ProvisionError::Held` gives below.
322 ProvisionError::Store(didbot_pds::StoreError::Held { .. }) => {
323 (StatusCode::FORBIDDEN, "AccountHeld")
324 }
325 ProvisionError::Store(..) => (StatusCode::INTERNAL_SERVER_ERROR, "StoreFailed"),
326 // 400: the caller's, and fixable by the caller, which is what
327 // separates it from `StorageFull` above. `RecordTooLarge` is
328 // spelled the way the blob lexicons spell `BlobTooLarge` — the
329 // `com.atproto.repo.*` lexicons declare no name for this because
330 // upstream bounds the request body instead, and a client that
331 // does not know the name still reads the sentence.
332 ProvisionError::Record(RecordError::TooLarge { .. }) => {
333 (StatusCode::PAYLOAD_TOO_LARGE, "RecordTooLarge")
334 }
335 // A record store failure is this server's, the same as the two
336 // above. Every other record error is something the caller wrote
337 // in the body and can correct.
338 ProvisionError::Record(RecordError::Backend(..)) => {
339 (StatusCode::INTERNAL_SERVER_ERROR, "RecordStoreFailed")
340 }
341 // `InvalidRecord` is a statement about the record's *contents*,
342 // and upstream uses it for exactly that. These record failures
343 // are not about the contents at all -- they are about
344 // the `collection` and `rkey` fields sitting beside the record in
345 // the request -- so they get `InvalidRequest`, which is what a
346 // client that reads the name will act on. Telling a caller its
347 // record is invalid when the record is fine and the rkey has a
348 // slash in it sends it to look in the wrong place.
349 ProvisionError::Record(
350 RecordError::MalformedCollection { .. }
351 | RecordError::UnusableRecordKey { .. }
352 | RecordError::KeyRefusedByStrategy { .. }
353 | RecordError::NothingToDelete { .. },
354 ) => (StatusCode::BAD_REQUEST, "InvalidRequest"),
355 // Not the caller's at all. A collection whose lexicon names a key
356 // strategy this build does not implement is a deployment that
357 // shipped a document it cannot file, and no request can be
358 // rewritten to work around it. The variant is there to fail
359 // loudly rather than silently mint a TID, and a 400 would point
360 // the wrong party at it.
361 ProvisionError::Record(RecordError::UnsupportedKeyStrategy { .. }) => {
362 (StatusCode::INTERNAL_SERVER_ERROR, "UnsupportedKeyStrategy")
363 }
364 // The name the lexicons declare, and now the correct one: a
365 // client that reads `InvalidSwap` re-reads the record, takes the
366 // CID it is given, and retries — which is exactly what will work
367 // here. That is the whole reason `UnsupportedSwap` had to
368 // exist and the whole reason it no longer does.
369 ProvisionError::Record(RecordError::SwapFailed { .. })
370 | ProvisionError::SwapCommitFailed { .. }
371 | ProvisionError::JudgedRecordMoved { .. } => (StatusCode::BAD_REQUEST, "InvalidSwap"),
372 // 403 rather than 400, and its own name: the request is well
373 // formed and the answer is that this caller may not do it. A client told
374 // `InvalidRecord` would rewrite its record and try again forever,
375 // because no record it can compose will be accepted here. The
376 // name is one an upstream client does not know, which is what
377 // stops it.
378 ProvisionError::Record(RecordError::ServerAuthored { .. }) => {
379 (StatusCode::FORBIDDEN, "ServerAuthoredRecord")
380 }
381 // 403 and its own name, for the same reason: the request is well
382 // formed, and no record the caller can compose changes the
383 // answer. What does change it is recreating one of the deleted
384 // records, which is something the caller can act on.
385 ProvisionError::Record(RecordError::TombstonesFull { .. }) => {
386 (StatusCode::FORBIDDEN, "TooManyDeletedRecords")
387 }
388 ProvisionError::Record(..) => (StatusCode::BAD_REQUEST, "InvalidRecord"),
389 // 403 and its own name, the posture of `TooManyDeletedRecords`:
390 // waiting does not change the answer, and deleting records does.
391 ProvisionError::TooManyRecords { .. } => (StatusCode::FORBIDDEN, "TooManyRecords"),
392 // A record this server accepted and now cannot put into a
393 // repository is this server's problem in the strongest sense: the
394 // write was acknowledged and the export cannot honour it.
395 ProvisionError::Repo(..) => (StatusCode::INTERNAL_SERVER_ERROR, "RepoExportFailed"),
396 // Never reached from here: every route that can raise one maps it
397 // through `crate::blobs::blob_error`, which knows the names the
398 // blob lexicons declare. This arm is the fallback for a caller
399 // that did not, and it says the honest thing rather than a name
400 // it invented.
401 ProvisionError::Blob(..) => (StatusCode::BAD_REQUEST, "InvalidRequest"),
402 // 403, the same posture as `ServerAccount`: the account exists,
403 // the caller is allowed to know that, and the refusal is about
404 // which state the account is in rather than about anything wrong
405 // with the request itself.
406 ProvisionError::NotWritable { .. } => (StatusCode::FORBIDDEN, "AccountNotWritable"),
407 // 403: a policy judged the write and refused it. The message
408 // carries the policy-authored reason (`plan/policy.md`'s
409 // "Detail flows to the party that already has it" — the caller
410 // sent the data, so the caller may be told what about it was
411 // refused), and nothing about the payload itself is echoed back
412 // from anywhere durable.
413 ProvisionError::PolicyRejected { .. } => (StatusCode::FORBIDDEN, "PolicyViolation"),
414 // 403: the creator is a hosted account and nothing in the
415 // operator's repository lets it create. Its own name, so the
416 // operator knows which record to write.
417 ProvisionError::NotAllowed { .. } => (StatusCode::FORBIDDEN, "NotAllowedToCreate"),
418 // 403, not `AccountNotWritable`: the account was writable a
419 // moment ago and this write is what quarantined it. A caller
420 // that retries sees `AccountNotWritable` instead, once the
421 // `Quarantined` lock has actually landed.
422 ProvisionError::PolicyFrozen { .. } => (StatusCode::FORBIDDEN, "AccountFrozen"),
423 // 403, the posture of `AccountNotWritable`: the account is
424 // locked, and the account's own credential is not what lifts a
425 // lock. The message names every tag on the hasp.
426 ProvisionError::Locked { .. } => (StatusCode::FORBIDDEN, "AccountLocked"),
427 // 403, the same posture: a hold is an operator's, and the
428 // account's own credential is not what clears it. The store's
429 // own refusal of the same hold maps above.
430 ProvisionError::Held { .. } => (StatusCode::FORBIDDEN, "AccountHeld"),
431 // 409, both: a stale picture of the holds, the shape
432 // `AccountAlreadyLocked` and `AccountNotLocked` take.
433 ProvisionError::AlreadyHeld { .. } => (StatusCode::CONFLICT, "AccountAlreadyHeld"),
434 ProvisionError::NotHeld { .. } => (StatusCode::CONFLICT, "AccountNotHeld"),
435 // 409, the same shape: the operator's picture of the account was
436 // stale, and the erasure they meant to hold has already started.
437 ProvisionError::ErasureUnderway { .. } => {
438 (StatusCode::CONFLICT, "AccountErasureUnderway")
439 }
440 // 409, both: the caller's picture of the hasp was stale. Its
441 // own tag is already hung, or the tag it asked to lift is not
442 // its own -- the same shape `InvalidAccountTransition` reports
443 // for a state.
444 ProvisionError::AlreadyLocked { .. } => (StatusCode::CONFLICT, "AccountAlreadyLocked"),
445 ProvisionError::NotLocked { .. } => (StatusCode::CONFLICT, "AccountNotLocked"),
446 // 503, the same posture as `StorageFull`: nothing was written,
447 // the request was well formed, and a caller that backs off and
448 // retries the same write may succeed once this repository's
449 // queue has drained. Not the caller's fault, and not evidence of
450 // anything wrong with the write itself.
451 ProvisionError::WriteQueueFull { .. } => {
452 (StatusCode::SERVICE_UNAVAILABLE, "WriteQueueFull")
453 }
454 // The refusal the route gives when it reads the stop itself: a
455 // write that met the stop at its commit is told the same thing.
456 ProvisionError::Halted(halted) => return Self::from(*halted),
457 // 413, like `RecordTooLarge`: nothing was stored, and a smaller
458 // write, or the same records over several commits, succeeds.
459 ProvisionError::CommitTooLarge { .. } => {
460 (StatusCode::PAYLOAD_TOO_LARGE, "CommitTooLarge")
461 }
462 // 400: the caller's own confirmation did not name the account it
463 // asked to destroy. Nothing about the account is wrong; the
464 // request was.
465 ProvisionError::ConfirmationMismatch { .. } => {
466 (StatusCode::BAD_REQUEST, "ConfirmationMismatch")
467 }
468 // 409: the account exists and the request is well-formed, but
469 // the state it names is not one this account can move to from
470 // where it is right now — the same shape `HandleTaken` reports
471 // for a request that would have worked at a different moment.
472 ProvisionError::InvalidTransition { .. } => {
473 (StatusCode::CONFLICT, "InvalidAccountTransition")
474 }
475 // The message carries the count and a child; the caller retires
476 // the children and asks again.
477 ProvisionError::LiveChildren { .. } => (StatusCode::CONFLICT, "AccountHasLiveChildren"),
478 ProvisionError::Kept { .. } => (StatusCode::CONFLICT, "AccountKept"),
479 // 403, and its own name: whatever the credential proved, the
480 // creator is not active. The message carries its state.
481 ProvisionError::CreatorNotActive { .. } => (StatusCode::FORBIDDEN, "CreatorNotActive"),
482 // 403: the credential verified, and names nobody a chain from
483 // here could reach the operator through.
484 ProvisionError::NotTheOperator { .. } => (StatusCode::FORBIDDEN, "NotTheOperator"),
485 // 403: the operator's record is there and names another account.
486 ProvisionError::OperatorRecordMismatch { .. } => {
487 (StatusCode::FORBIDDEN, "OperatorRecordMismatch")
488 }
489 ProvisionError::AccountKey(..) => (StatusCode::BAD_REQUEST, "InvalidKey"),
490 ProvisionError::NameRequired => (StatusCode::BAD_REQUEST, "NameRequired"),
491 // 403, each its own name: the deployment's own bound on the
492 // tree, independent of any policy, so a client can tell which
493 // one it met.
494 ProvisionError::TooDeep { .. } => (StatusCode::FORBIDDEN, "TreeTooDeep"),
495 ProvisionError::TooManyChildren { .. } => (StatusCode::FORBIDDEN, "TooManyChildren"),
496 // 429: the creator is not wrong, only early; the message says
497 // how many it made this hour.
498 ProvisionError::CreatesTooFast { .. } => {
499 (StatusCode::TOO_MANY_REQUESTS, "CreatesTooFast")
500 }
501 // 429, the name the reference PDS gives a rate refusal; the
502 // message names the bucket and when to retry.
503 ProvisionError::OverWriteBudget { .. } => {
504 (StatusCode::TOO_MANY_REQUESTS, "RateLimitExceeded")
505 }
506 ProvisionError::AncestorNotStanding { .. } => {
507 (StatusCode::FORBIDDEN, "AncestorNotStanding")
508 }
509 // Only reachable in-process: the poll asks about roots alone.
510 ProvisionError::NotARoot { .. } => (StatusCode::CONFLICT, "NotARoot"),
511 // The credentials ended in this process; the message says a
512 // restart may bring them back.
513 ProvisionError::TokensUnkept { .. } => {
514 (StatusCode::INTERNAL_SERVER_ERROR, "TokenStoreFailed")
515 }
516 ProvisionError::LoginsUnkept { .. } => {
517 (StatusCode::INTERNAL_SERVER_ERROR, "GrantStoreFailed")
518 }
519 // `bot.did.updateCredentials`'s refusals, each the name its
520 // lexicon declares.
521 ProvisionError::CredentialNotFound { .. } => {
522 (StatusCode::NOT_FOUND, "CredentialNotFound")
523 }
524 ProvisionError::CredentialExists { .. } => (StatusCode::CONFLICT, "CredentialExists"),
525 ProvisionError::TooManyCredentials { .. } => {
526 (StatusCode::CONFLICT, "TooManyCredentials")
527 }
528 ProvisionError::LastCredential { .. } => (StatusCode::CONFLICT, "LastCredential"),
529 ProvisionError::AccountNotActive { .. } => (StatusCode::FORBIDDEN, "AccountNotActive"),
530 ProvisionError::ReplacedTooSoon { .. } => {
531 (StatusCode::TOO_MANY_REQUESTS, "RateLimitExceeded")
532 }
533 };
534 let mapped = Self::new(status, name, err.to_string());
535 match err {
536 ProvisionError::OverWriteBudget {
537 retry_after_secs: Some(secs),
538 ..
539 } => mapped.with_header(axum::http::header::RETRY_AFTER, secs.to_string()),
540 _ => mapped,
541 }
542 }
543}
544
545/// The same mapping, as the `com.atproto.repo.*` surface reports it.
546///
547/// One renaming and nothing else: an account this deployment does not hold is
548/// `UnknownAccount` to `bot.did.*`, which is a statement about an
549/// account, and `RepoNotFound` to `com.atproto.repo.*`, which is a statement
550/// about a repository. They are the same condition seen from two surfaces,
551/// and each surface uses the name its own clients are written against.
552///
553/// It exists as a function rather than as a second `From` so there is one
554/// place where the difference between the two surfaces is written down, and
555/// so a new variant added to [`ProvisionError`] gets its status and name once.
556pub fn repo_surface(err: &ProvisionError) -> ApiError {
557 let mapped = ApiError::from(err);
558 match err {
559 ProvisionError::UnknownAccount { did } => ApiError::repo_not_found(did),
560 _ => mapped,
561 }
562}
563
564impl IntoResponse for ApiError {
565 fn into_response(self) -> Response {
566 let mut response = (self.status, Json(self.body)).into_response();
567 for (name, value) in self.headers {
568 response.headers_mut().insert(name, value);
569 }
570 response
571 }
572}
573
574/// The name a request gets when the e-stop has halted it.
575///
576/// `Halted` rather than a generic `MethodNotImplemented` or an ordinary 503:
577/// atproto clients that do not know the name still see a clear
578/// "unavailable", and `plan/e-stop.md`'s own requirement is that the ones
579/// that do know it can tell a halt apart from every other reason a request
580/// might fail. The message says whose latch it is, because that decides
581/// whether waiting helps: an operator's stop stays until the operator
582/// releases it, and this server's own pause lifts when the operator's record
583/// is back. Either way, retrying before then does not.
584impl From<Halted> for ApiError {
585 fn from(halted: Halted) -> Self {
586 let advice = match halted.cause {
587 EstopCause::OperatorThrown => {
588 "This is an operator emergency stop, not a transient failure: do not retry, \
589 and do not treat this as recoverable by anything the caller can do."
590 }
591 EstopCause::OperatorMissing => "Retrying before then will not help.",
592 };
593 Self::new(
594 StatusCode::SERVICE_UNAVAILABLE,
595 "Halted",
596 format!("{halted}. {advice}"),
597 )
598 }
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604 use didbot_pds::StoreError;
605
606 /// A duplicate is distinguishable from a store that is merely failing.
607 ///
608 /// Both assertions are the test. The first says a duplicate is a 409
609 /// under its own name; the second says an ordinary store failure still
610 /// is not, which is what stops the first from being satisfied by
611 /// handing every `StoreError` the new status. A caller that cannot tell
612 /// these two apart has to either retry a refusal that will never change
613 /// or give up on a failure that would have cleared.
614 #[test]
615 fn a_duplicate_account_is_not_reported_as_a_failing_store() {
616 let duplicate = ApiError::from(&ProvisionError::Store(StoreError::AlreadyExists {
617 did: "did:web:twin.agents.localhost".to_owned(),
618 }));
619 assert_eq!(duplicate.status(), StatusCode::CONFLICT);
620 assert_eq!(duplicate.error(), "AccountAlreadyExists");
621
622 let unwell = ApiError::from(&ProvisionError::Store(StoreError::Backend(
623 "the disk went away".to_owned(),
624 )));
625 assert_eq!(unwell.status(), StatusCode::INTERNAL_SERVER_ERROR);
626 assert_eq!(unwell.error(), "StoreFailed");
627 }
628
629 /// Pulling variants out of the catch-all left the rest where they were.
630 /// `Full` is the arm that was already carved out and must stay carved
631 /// out; `NotFound` still falls through.
632 #[test]
633 fn every_other_store_failure_keeps_its_mapping() {
634 let full = ApiError::from(&ProvisionError::Store(StoreError::Full {
635 reason: "the log reached its budget".to_owned(),
636 }));
637 assert_eq!(full.status(), StatusCode::INSUFFICIENT_STORAGE);
638 assert_eq!(full.error(), "StorageFull");
639
640 let missing = ApiError::from(&ProvisionError::Store(StoreError::NotFound {
641 did: "did:web:absent.agents.localhost".to_owned(),
642 }));
643 assert_eq!(missing.status(), StatusCode::INTERNAL_SERVER_ERROR);
644 assert_eq!(missing.error(), "StoreFailed");
645 }
646}