Skip to main content

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}