Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
35 kB · 661 lines
Rust
at commit 18ba4fe0
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662//! The error body every failing route returns.
use axum::http::{HeaderName, HeaderValue, StatusCode};use axum::response::{IntoResponse, Response};use axum::Json;use didbot_pds::{Halted, ProvisionError, RecordError};use serde::{Deserialize, Serialize};
/// An atproto XRPC error body: a machine-readable name and a human sentence.////// atproto clients switch on `error` and only show `message`, so the name is/// the stable part. Renaming one is a wire break even though nothing here/// checks it.#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]pub struct ErrorBody { /// Machine-readable error name, e.g. `AttestationFailed`. pub error: String, /// One sentence for a human reading a terminal. pub message: String,}
/// A failure rendered as an HTTP status plus an [`ErrorBody`].#[derive(Debug, Clone, PartialEq, Eq)]pub struct ApiError { status: StatusCode, body: ErrorBody, /// Extra response headers, such as `WWW-Authenticate` or `DPoP-Nonce`. /// /// A client's next move after `AuthenticationRequired`, `ExpiredToken` /// and `InvalidToken` differs — ask for a credential, refresh, or give /// up — and a header is how it learns which without parsing the message. /// Empty for almost every error this crate raises, which is why it is a /// field rather than a second constructor for every variant above. headers: Vec<(HeaderName, HeaderValue)>,}
impl ApiError { /// Builds an error response from its parts. pub fn new(status: StatusCode, error: impl Into<String>, message: impl Into<String>) -> Self { Self { status, body: ErrorBody { error: error.into(), message: message.into(), }, headers: Vec::new(), } }
/// Attaches a response header, such as `WWW-Authenticate`. #[must_use] pub fn with_header(mut self, name: HeaderName, value: impl Into<String>) -> Self { if let Ok(value) = HeaderValue::from_str(&value.into()) { self.headers.push((name, value)); } self }
/// A 400 for a body that did not parse or did not make sense. pub fn bad_request(message: impl Into<String>) -> Self { Self::new(StatusCode::BAD_REQUEST, "InvalidRequest", message) }
/// No repository here, under either a DID or a handle. /// /// `RepoNotFound` is spelled as atproto's own personal data server spells /// it. The `com.atproto.repo.*` lexicons declare no error for it — only /// `RecordNotFound` and `InvalidSwap` — but every `com.atproto.sync.*` /// lexicon that can miss a repository declares this name, and the /// reference implementation raises it from `describeRepo`, /// `deleteRecord` and `putRecord` alike. Inventing a second spelling for /// the same condition is the failure this constructor exists to avoid. /// /// 404 rather than the reference implementation's 400. XRPC clients /// switch on the name and not the status, the name is the one they know, /// and "not found" is what the status field is for. pub fn repo_not_found(repo: &str) -> Self { Self::new( StatusCode::NOT_FOUND, "RepoNotFound", format!("could not find a repository for `{repo}`"), ) }
/// The authenticated agent is not the one the repository belongs to. /// /// `RepoMismatch`, its own name rather than a reused one: an agent /// token is real and unexpired — [`ApiError::new`] with /// `AuthenticationRequired`, `ExpiredToken` or `InvalidToken` is what a /// route answers before this is ever reached — and the caller needs to /// know that what failed is *whose* repository this is, which no retry /// of the same credential against the same repository can fix. /// `plan/auth-types.md` treats "an agent never writes another agent's /// repository" as an invariant rather than a policy knob, and this is /// where it is enforced: `routes::write_record` and its siblings compare /// the DID `auth::require_agent_token` resolved against the repository /// the request names, before any write reaches the registry. pub fn repo_mismatch(authenticated: &str, repo: &str) -> Self { Self::new( StatusCode::FORBIDDEN, "RepoMismatch", format!( "`{authenticated}` may not write to `{repo}`'s repository; an agent's \ credential only authorizes writes to its own" ), ) }
/// The authenticated agent is not the account this admin route names. /// /// `AccountMismatch`, its own name for the same reason [`Self::repo_mismatch`] /// has one: an agent token is real and unexpired, and what failed is /// *whose* account this is. Every account-lifecycle mutation is /// self-service — an agent's own credential, checked against the account /// the request names, is the whole of what authorizes them; see /// `plan/auth-types.md`. This is what that credential gets for naming /// somebody else's account. pub fn account_mismatch(authenticated: &str, target: &str) -> Self { Self::new( StatusCode::FORBIDDEN, "AccountMismatch", format!( "`{authenticated}` may not act on `{target}`'s account; an agent's own \ credential only authorizes acting on itself" ), ) }
/// `bot.did.provisionAgent` was asked to mint with no attestation claim /// to check; see `crate::auth::require_attestation`. /// /// 403 rather than 401: the claim travels in the body, not in a header /// there is a challenge scheme for, so there is no `WWW-Authenticate` a /// 401 could honestly carry. Its own name rather than /// `AttestationRefused`, which is a claim that was presented and did not /// verify; a daemon reading this one has to go and get an identity, not /// re-sign. pub fn attestation_required() -> Self { Self::new( StatusCode::FORBIDDEN, "AttestationRequired", "bot.did.provisionAgent takes a claim signed by the node key of an admitted \ host, and the request carries none", ) }
/// A caller has exceeded a rate limit; see `crate::rate_limit`. /// /// `RateLimitExceeded` names no error the lexicons declare — the same /// position `MethodNotImplemented`'s own doc note (`routes::unknown_route`) /// describes for a route that does not exist at all. 429 is the standard /// HTTP status for this and every client is expected to already know it /// even when the name is new to it. pub fn rate_limited(message: impl Into<String>) -> Self { Self::new(StatusCode::TOO_MANY_REQUESTS, "RateLimitExceeded", message) }
/// This deployment already holds as many accounts as its operator has /// configured room for. /// /// Its own name rather than `Halted`: that name's own doc tells a caller /// not to retry because an operator threw a switch that will not clear /// on its own, and neither is true of a full deployment — an account /// leaving, or an operator raising the cap, both clear this without /// anyone having pulled anything. 503, the same posture /// `ProvisionError::WriteQueueFull` takes: a statement about the /// deployment, not about the caller. pub fn account_cap_reached(cap: u64) -> Self { Self::new( StatusCode::SERVICE_UNAVAILABLE, "AccountCapReached", format!("this deployment already holds its configured maximum of {cap} accounts"), ) }
/// This deployment has not finished being born, or has lost its public /// standing, and the route asked for is one it therefore defers. /// /// `503` with `Retry-After`, and an error name — `ServerNotReady` — no /// lexicon declares, the same position `RateLimitExceeded` above is in. /// The status and the header are what a caller actually reads, and both /// are standard. /// /// # What a relay is meant to conclude /// /// Three conditions a crawler has to tell apart, and this is exactly /// one of them: /// /// - **Try later** — this. `503`, a `Retry-After`, a named reason. The /// server is fine and is not finished. Retry on the hint. /// - **Broken** — a refused connection, a `502` from something in /// front, or a `500` with no `Retry-After`. Nothing emits a `500` for /// this condition, which is what makes the absence of the header /// meaningful. /// - **Nothing here** — `200` with an empty list or stream. Fine, and /// holds nothing. Nothing to retry. /// /// Never a `404`: "this method does not exist" and "this method is not /// ready" are facts a relay reacts to completely differently, and only /// one of them is worth ever asking about again. /// /// The message names the state, which for an `unclaimed` server is the /// whole answer: nobody is currently answerable for this deployment, and /// the fix is to run `didbot-claim`. `/onboarding` says the same thing /// with the command attached. pub fn not_ready(route: &str, snapshot: &didbot_pds::LifecycleSnapshot) -> Self { Self::new( StatusCode::SERVICE_UNAVAILABLE, "ServerNotReady", format!( "this deployment is `{}` and does not serve `{route}`; retry after {}s", snapshot.state, didbot_pds::RETRY_AFTER_SECONDS, ), ) .with_header( axum::http::header::RETRY_AFTER, didbot_pds::RETRY_AFTER_SECONDS.to_string(), ) }
/// The repository is here and the record is not. /// /// Declared by `com.atproto.repo.getRecord`, which is the one route that /// returns it: `deleteRecord` is specified to succeed when the record was /// already absent. pub fn record_not_found(uri: &str) -> Self { Self::new( StatusCode::NOT_FOUND, "RecordNotFound", format!("could not locate record: {uri}"), ) }
/// The status this error responds with. pub fn status(&self) -> StatusCode { self.status }
/// The machine-readable error name. pub fn error(&self) -> &str { &self.body.error }
/// The human-readable message. pub fn message(&self) -> &str { &self.body.message }}
impl From<&ProvisionError> for ApiError { /// Maps a provisioning failure onto a status and an error name. /// /// The `Display` text of the underlying error becomes the message. Every /// error in the chain is written for the caller to read: a secret, a /// signing key and the path to a file this deployment keeps are each kept /// out of it, and a store refusal carries only the sizes and the sentence /// that are about the request itself. fn from(err: &ProvisionError) -> Self { let (status, name) = match err { ProvisionError::Identity(..) => (StatusCode::BAD_REQUEST, "InvalidIdentity"), // Unreachable against a correct mint; if it ever fires, it is // this server's minting logic at fault, not the caller's request. ProvisionError::MintEscapesZone { .. } => { (StatusCode::INTERNAL_SERVER_ERROR, "MintEscapesZone") } // Its own name rather than InvalidIdentity: the handle and the // agent id are two fields of the request, and a caller told only // "invalid identity" would go and check the wrong one. ProvisionError::Handle(..) => (StatusCode::BAD_REQUEST, "InvalidHandle"), ProvisionError::HandleEscapesZone { .. } => { (StatusCode::BAD_REQUEST, "HandleEscapesZone") } // 409, not 400: the request is well-formed and would have been // accepted a moment before somebody else took the name. ProvisionError::HandleTaken { .. } => (StatusCode::CONFLICT, "HandleTaken"), ProvisionError::UnknownAccount { .. } => (StatusCode::NOT_FOUND, "UnknownAccount"), // Only reachable from a route outside `com.atproto.sync.*`, which // no route currently is: the sync routes name this // `BlockNotFound`, which is what their lexicons declare. See // `routes::sync_error`. ProvisionError::BlockNotFound { .. } => (StatusCode::BAD_REQUEST, "BlockNotFound"), // 403: the account exists and the caller may know that, and the // refusal is about which account it is rather than about the // request. Nothing a caller can change makes this one succeed. ProvisionError::ServerAccount { .. } => (StatusCode::FORBIDDEN, "ServerAccount"), // Naming is configuration, and the caller supplies at most a hint // that is dropped when it cannot be honoured. Every way this // fails -- a namer that will not answer, a pool with nothing left // in it -- is the deployment's to fix and not the caller's. ProvisionError::Name(..) => (StatusCode::INTERNAL_SERVER_ERROR, "NamingFailed"), // DNS and the store are this server's own dependencies. A caller // cannot fix either, so they are not the caller's fault. ProvisionError::Dns(..) => (StatusCode::INTERNAL_SERVER_ERROR, "DnsFailed"), // 507, and its own name, before the general store arm below it. // A deployment that has run out of room is not broken and is not // the caller's fault either: the request was well formed, nothing // was written, and the same request works again once there is // room. `StorageFull` is what tells a client to stop writing and // come back rather than retry immediately or give up, which is // the distinction `plan/store-scale.md` calls "refuse rather than // exhaust". A 500 says none of that. ProvisionError::Store(didbot_pds::StoreError::Full { .. }) | ProvisionError::Record(RecordError::StorageFull { .. }) => { (StatusCode::INSUFFICIENT_STORAGE, "StorageFull") } // 409, and its own name, before the general store arm below it. // A DID this deployment already holds is the one store refusal // that is not a store failure: nothing is unwell, nothing was // written, and the request will be refused the same way forever // because `insert` is not an upsert (see // [`didbot_pds::StoreError::AlreadyExists`]). A 500 tells a // caller to back off and retry, which is exactly the wrong move // -- it is the same posture `StorageFull` is carved out of this // arm for, and the same 409 `HandleTaken` and // `InvalidAccountTransition` report for a well-formed request // that collides with state already here. // // `AccountAlreadyExists` names no error the lexicons declare, // the same position `RateLimitExceeded` and `ServerNotReady` are // in: no `com.atproto.*` lexicon has a name for this because // upstream mints a DID nobody asked for rather than one derived // from a caller's `agentId`, so the collision cannot arise there. // `DuplicateCreate` is declared, but only by // `com.atproto.server.getAccountInviteCodes` and only about // invite codes; reusing it here would tell a client something // false. 409 is the standard status and every client already // knows it, and the name is there for the ones that switch on it. ProvisionError::Store(didbot_pds::StoreError::AlreadyExists { .. }) => { (StatusCode::CONFLICT, "AccountAlreadyExists") } // 409, and its own name, before the general store arm below it. // The other store refusal that is not a store failure: nothing // is unwell and nothing was written, the account is exactly as // it was, and `delete_as` logs this at `info` because it is a // deliberate refusal rather than something going wrong. A 500 // tells a caller to back off and retry, and retrying changes // nothing while the pin stands. // // 409 rather than the 403 the neighbouring refusals take. // `ServerAccount`, `AccountNotWritable` and `AccountFrozen` are // 403 because the refusal is about *which account it is* or // what state and locks it carries -- an authority the caller // does not have and cannot give itself. A pin is neither: it is a // value on the retention axis, which // [`plan/account-types.md`](../../../plan/account-types.md) // keeps deliberately independent of `AccountState` because "the // two answer different questions". The caller is authorised -- // `bot.did.setAgentPinned` takes the same `Credential::AgentSelf` // `bot.did.deleteAgent` does, so the principal that is refused // here holds the credential that lifts it, and the message // already says so ("unpin it first"). A well-formed request // colliding with state already here, which the caller can clear // and retry, is exactly what `HandleTaken`, // `InvalidAccountTransition` and `AccountAlreadyExists` report. // // `AccountPinned` names no error the lexicons declare, the same // position `AccountAlreadyExists` above is in: this repository // declares two lexicons and both are records, and no // `com.atproto.*` lexicon has a name for this because upstream // has no retention axis to pin. `RepoTakendown`, `RepoSuspended` // and `RepoDeactivated` are declared and are the closest-looking // names, but `docs/conformance.md`'s "The account statuses this // deployment does not have" says this server has no takedown, no // suspension and no deactivation -- reusing one would tell a // client something false. ProvisionError::Store(didbot_pds::StoreError::Pinned { .. }) => { (StatusCode::CONFLICT, "AccountPinned") } // 403, beside the pin: the store's own refusal of a held // account, the same answer `ProvisionError::Held` gives below. ProvisionError::Store(didbot_pds::StoreError::Held { .. }) => { (StatusCode::FORBIDDEN, "AccountHeld") } ProvisionError::Store(..) => (StatusCode::INTERNAL_SERVER_ERROR, "StoreFailed"), // 400: the caller's, and fixable by the caller, which is what // separates it from `StorageFull` above. `RecordTooLarge` is // spelled the way the blob lexicons spell `BlobTooLarge` — the // `com.atproto.repo.*` lexicons declare no name for this because // upstream bounds the request body instead, and a client that // does not know the name still reads the sentence. ProvisionError::Record(RecordError::TooLarge { .. }) => { (StatusCode::PAYLOAD_TOO_LARGE, "RecordTooLarge") } // A record store failure is this server's, the same as the two // above. Every other record error is something the caller wrote // in the body and can correct. ProvisionError::Record(RecordError::Backend(..)) => { (StatusCode::INTERNAL_SERVER_ERROR, "RecordStoreFailed") } // `InvalidRecord` is a statement about the record's *contents*, // and upstream uses it for exactly that. Two of the record // failures are not about the contents at all -- they are about // the `collection` and `rkey` fields sitting beside the record in // the request -- so they get `InvalidRequest`, which is what a // client that reads the name will act on. Telling a caller its // record is invalid when the record is fine and the rkey has a // slash in it sends it to look in the wrong place. ProvisionError::Record( RecordError::MalformedCollection { .. } | RecordError::UnusableRecordKey { .. } | RecordError::KeyRefusedByStrategy { .. }, ) => (StatusCode::BAD_REQUEST, "InvalidRequest"), // Not the caller's at all. A collection whose lexicon names a key // strategy this build does not implement is a deployment that // shipped a document it cannot file, and no request can be // rewritten to work around it. The variant is there to fail // loudly rather than silently mint a TID, and a 400 would point // the wrong party at it. ProvisionError::Record(RecordError::UnsupportedKeyStrategy { .. }) => { (StatusCode::INTERNAL_SERVER_ERROR, "UnsupportedKeyStrategy") } // The name the lexicons declare, and now the correct one: a // client that reads `InvalidSwap` re-reads the record, takes the // CID it is given, and retries — which is exactly what will work // here. That is the whole reason `UnsupportedSwap` had to // exist and the whole reason it no longer does. ProvisionError::Record(RecordError::SwapFailed { .. }) | ProvisionError::SwapCommitFailed { .. } | ProvisionError::JudgedRecordMoved { .. } => (StatusCode::BAD_REQUEST, "InvalidSwap"), // 403 rather than 400, and its own name: the request is well // formed and the answer is that this caller may not do it. A client told // `InvalidRecord` would rewrite its record and try again forever, // because no record it can compose will be accepted here. The // name is one an upstream client does not know, which is what // stops it. ProvisionError::Record(RecordError::ServerAuthored { .. }) => { (StatusCode::FORBIDDEN, "ServerAuthoredRecord") } ProvisionError::Record(..) => (StatusCode::BAD_REQUEST, "InvalidRecord"), // A record this server accepted and now cannot put into a // repository is this server's problem in the strongest sense: the // write was acknowledged and the export cannot honour it. ProvisionError::Repo(..) => (StatusCode::INTERNAL_SERVER_ERROR, "RepoExportFailed"), // Never reached from here: every route that can raise one maps it // through `crate::blobs::blob_error`, which knows the names the // blob lexicons declare. This arm is the fallback for a caller // that did not, and it says the honest thing rather than a name // it invented. ProvisionError::Blob(..) => (StatusCode::BAD_REQUEST, "InvalidRequest"), // 403, the same posture as `ServerAccount`: the account exists, // the caller is allowed to know that, and the refusal is about // which state the account is in rather than about anything wrong // with the request itself. ProvisionError::NotWritable { .. } => (StatusCode::FORBIDDEN, "AccountNotWritable"), // 403: a policy judged the write and refused it. The message // carries the policy-authored reason (`plan/policy.md`'s // "Detail flows to the party that already has it" — the caller // sent the data, so the caller may be told what about it was // refused), and nothing about the payload itself is echoed back // from anywhere durable. ProvisionError::PolicyRejected { .. } => (StatusCode::FORBIDDEN, "PolicyViolation"), // 403, not `AccountNotWritable`: the account was writable a // moment ago and this write is what quarantined it. A caller // that retries sees `AccountNotWritable` instead, once the // `Quarantined` lock has actually landed. ProvisionError::PolicyFrozen { .. } => (StatusCode::FORBIDDEN, "AccountFrozen"), // 403, the posture of `AccountNotWritable`: the account is // locked, and the account's own credential is not what lifts a // lock. The message names every tag on the hasp. ProvisionError::Locked { .. } => (StatusCode::FORBIDDEN, "AccountLocked"), // 403, the same posture: a hold is an operator's, and the // account's own credential is not what clears it. The store's // own refusal of the same hold maps beside the pin's below. ProvisionError::Held { .. } => (StatusCode::FORBIDDEN, "AccountHeld"), // 409, both: a stale picture of the holds, the shape // `AccountAlreadyLocked` and `AccountNotLocked` take. ProvisionError::AlreadyHeld { .. } => (StatusCode::CONFLICT, "AccountAlreadyHeld"), ProvisionError::NotHeld { .. } => (StatusCode::CONFLICT, "AccountNotHeld"), // 409, both: the caller's picture of the hasp was stale. Its // own tag is already hung, or the tag it asked to lift is not // its own -- the same shape `InvalidAccountTransition` reports // for a state. ProvisionError::AlreadyLocked { .. } => (StatusCode::CONFLICT, "AccountAlreadyLocked"), ProvisionError::NotLocked { .. } => (StatusCode::CONFLICT, "AccountNotLocked"), // 503, the same posture as `StorageFull`: nothing was written, // the request was well formed, and a caller that backs off and // retries the same write may succeed once this repository's // queue has drained. Not the caller's fault, and not evidence of // anything wrong with the write itself. ProvisionError::WriteQueueFull { .. } => { (StatusCode::SERVICE_UNAVAILABLE, "WriteQueueFull") } // 400: the caller's own confirmation did not name the account it // asked to destroy. Nothing about the account is wrong; the // request was. ProvisionError::ConfirmationMismatch { .. } => { (StatusCode::BAD_REQUEST, "ConfirmationMismatch") } // 409: the account exists and the request is well-formed, but // the state it names is not one this account can move to from // where it is right now — the same shape `HandleTaken` reports // for a request that would have worked at a different moment. ProvisionError::InvalidTransition { .. } => { (StatusCode::CONFLICT, "InvalidAccountTransition") } // The edge that failed is in the message; the name says only // that the chain did not reach a root. ProvisionError::Admission(..) => (StatusCode::FORBIDDEN, "AdmissionRefused"), // The message carries the count and a child; the caller retires // the children and asks again. ProvisionError::LiveChildren { .. } => (StatusCode::CONFLICT, "AccountHasLiveChildren"), // 503: the same shape `WriteQueueFull` has. Nothing about the // request is wrong; the queue an unvouched caller may fill is // full, and it drains as reservations expire or are claimed. ProvisionError::ReservationsFull { .. } => { (StatusCode::SERVICE_UNAVAILABLE, "QueueFull") } ProvisionError::NotReservable { .. } => (StatusCode::BAD_REQUEST, "KindNotReservable"), // 503: a deployment without a name pool has nothing to reserve // from, and that is its configuration rather than the caller's // request. ProvisionError::NoNamePool => (StatusCode::SERVICE_UNAVAILABLE, "NoNamePool"), ProvisionError::NotReservation { .. } => (StatusCode::CONFLICT, "NotAReservation"), ProvisionError::ReservationExpired { .. } => { (StatusCode::CONFLICT, "ReservationExpired") } // 403: the signature did not verify, or names a host this // deployment holds no key for; which of the two is in the // message, not the name. ProvisionError::Attestation(..) => (StatusCode::FORBIDDEN, "AttestationRefused"), // 403, and its own name: whatever the signature, the host the // claim names is not active. The message carries its state, so a // reservation still waiting for its operator's vouch reads as // exactly that. ProvisionError::HostNotAdmitted { .. } => (StatusCode::FORBIDDEN, "HostNotAdmitted"), ProvisionError::ParentDisagrees { .. } => (StatusCode::BAD_REQUEST, "ParentDisagrees"), }; Self::new(status, name, err.to_string()) }}
/// The same mapping, as the `com.atproto.repo.*` surface reports it.////// One renaming and nothing else: an account this deployment does not hold is/// `UnknownAccount` to `bot.did.*`, which is a statement about an/// account, and `RepoNotFound` to `com.atproto.repo.*`, which is a statement/// about a repository. They are the same condition seen from two surfaces,/// and each surface uses the name its own clients are written against.////// It exists as a function rather than as a second `From` so there is one/// place where the difference between the two surfaces is written down, and/// so a new variant added to [`ProvisionError`] gets its status and name once.pub fn repo_surface(err: &ProvisionError) -> ApiError { let mapped = ApiError::from(err); match err { ProvisionError::UnknownAccount { did } => ApiError::repo_not_found(did), _ => mapped, }}
impl IntoResponse for ApiError { fn into_response(self) -> Response { let mut response = (self.status, Json(self.body)).into_response(); for (name, value) in self.headers { response.headers_mut().insert(name, value); } response }}
/// The name a request gets when the e-stop has halted it.////// `Halted` rather than a generic `MethodNotImplemented` or an ordinary 503:/// atproto clients that do not know the name still see a clear/// "unavailable", and `plan/e-stop.md`'s own requirement is that the ones/// that do know it can tell "the operator stopped everything and will not/// clear on its own" apart from every other reason a request might fail —/// which is exactly the distinction an agent needs to stop retrying rather/// than to back off and try again.impl From<Halted> for ApiError { fn from(halted: Halted) -> Self { Self::new( StatusCode::SERVICE_UNAVAILABLE, "Halted", format!( "{halted}. This is an operator emergency stop, not a transient failure: do not \ retry, and do not treat this as recoverable by anything the caller can do." ), ) }}
#[cfg(test)]mod tests { use super::*; use didbot_pds::StoreError;
/// A duplicate is distinguishable from a store that is merely failing. /// /// Both assertions are the test. The first says a duplicate is a 409 /// under its own name; the second says an ordinary store failure still /// is not, which is what stops the first from being satisfied by /// handing every `StoreError` the new status. A caller that cannot tell /// these two apart has to either retry a refusal that will never change /// or give up on a failure that would have cleared. #[test] fn a_duplicate_account_is_not_reported_as_a_failing_store() { let duplicate = ApiError::from(&ProvisionError::Store(StoreError::AlreadyExists { did: "did:web:twin.agents.localhost".to_owned(), })); assert_eq!(duplicate.status(), StatusCode::CONFLICT); assert_eq!(duplicate.error(), "AccountAlreadyExists");
let unwell = ApiError::from(&ProvisionError::Store(StoreError::Backend( "the disk went away".to_owned(), ))); assert_eq!(unwell.status(), StatusCode::INTERNAL_SERVER_ERROR); assert_eq!(unwell.error(), "StoreFailed"); }
/// Pulling variants out of the catch-all left the rest where they were. /// `Full` is the arm that was already carved out and must stay carved /// out; `NotFound` still falls through. #[test] fn every_other_store_failure_keeps_its_mapping() { let full = ApiError::from(&ProvisionError::Store(StoreError::Full { reason: "the log reached its budget".to_owned(), })); assert_eq!(full.status(), StatusCode::INSUFFICIENT_STORAGE); assert_eq!(full.error(), "StorageFull");
let missing = ApiError::from(&ProvisionError::Store(StoreError::NotFound { did: "did:web:absent.agents.localhost".to_owned(), })); assert_eq!(missing.status(), StatusCode::INTERNAL_SERVER_ERROR); assert_eq!(missing.error(), "StoreFailed"); }
/// A pin is distinguishable from a store that is merely failing, and /// from the duplicate that shares its status. /// /// Three assertions and all three are the test. The first says a pin is /// a 409 under its own name; the second says an ordinary store failure /// still is not, which is what stops the first from being satisfied by /// handing every `StoreError` the new status; the third says the name /// is not `AccountAlreadyExists`, which is what stops it from being /// satisfied by widening the arm above. An operator set this pin /// deliberately and `delete_as` logs the refusal at `info`; reporting /// it as a 5xx tells a caller to retry something that cannot change /// while the pin stands. #[test] fn a_pinned_account_is_not_reported_as_a_failing_store() { let pinned = ApiError::from(&ProvisionError::Store(StoreError::Pinned { did: "did:web:kept.agents.localhost".to_owned(), })); assert_eq!(pinned.status(), StatusCode::CONFLICT); assert_eq!(pinned.error(), "AccountPinned");
let unwell = ApiError::from(&ProvisionError::Store(StoreError::Backend( "the disk went away".to_owned(), ))); assert_eq!(unwell.status(), StatusCode::INTERNAL_SERVER_ERROR); assert_eq!(unwell.error(), "StoreFailed");
let duplicate = ApiError::from(&ProvisionError::Store(StoreError::AlreadyExists { did: "did:web:twin.agents.localhost".to_owned(), })); assert_eq!(duplicate.error(), "AccountAlreadyExists"); }}