Skip to main content

dinio_sdk/
error.rs

1//! SDK error types. Core's error envelope (`error.code` / `message` / `details`) is kept
2//! intact in [`ApiError`], never collapsed into a string.
3
4use std::fmt;
5
6use crate::models::ImportReport;
7
8/// Everything an SDK call can fail with.
9///
10/// # Examples
11///
12/// Telling "does not exist" apart from other failures:
13///
14/// ```no_run
15/// use dinio_sdk::{Error, ErrorCode};
16///
17/// # async fn example(client: &dinio_sdk::Client) -> dinio_sdk::Result<()> {
18/// match client.zones().get("example.com").await {
19///     Ok(zone) => println!("zone {}", zone.uuid),
20///     Err(Error::Api(err)) if err.code == ErrorCode::NotFound => println!("no such zone"),
21///     Err(err) => return Err(err),
22/// }
23/// # Ok(())
24/// # }
25/// ```
26#[derive(Debug, thiserror::Error)]
27pub enum Error {
28    /// Core answered with its error envelope (`success: false`).
29    #[error(transparent)]
30    Api(#[from] ApiError),
31
32    /// The request never got a response: DNS, TLS, connection, timeout.
33    #[error("request to Core failed: {}", chain(.0))]
34    Transport(#[source] reqwest::Error),
35
36    /// Core (or something in front of it, e.g. a proxy) answered with something that is
37    /// not the expected envelope.
38    #[error("unexpected response (HTTP {status}): {snippet}")]
39    Decode {
40        /// HTTP status of the response.
41        status: u16,
42        /// Start of the response body (up to 200 characters), for diagnosis.
43        snippet: String,
44    },
45
46    /// Rejected by the SDK before sending anything (bad URL, bad argument).
47    #[error("invalid input: {0}")]
48    InvalidInput(String),
49
50    /// A name-based operation found nothing to act on (e.g. no record value with that data).
51    #[error("not found: {0}")]
52    NotFound(String),
53}
54
55/// Core's error envelope plus the HTTP status it came with.
56#[derive(Debug, Clone, thiserror::Error)]
57#[error("{message} ({code}, HTTP {status})")]
58pub struct ApiError {
59    /// HTTP status of the response (401, 403, 404, 409, 422, ...).
60    pub status: u16,
61    /// Machine-readable `error.code`; match on this, not on the message.
62    pub code: ErrorCode,
63    /// Human-readable `error.message`, as Core wrote it.
64    pub message: String,
65    /// Structured detail, e.g. the list of violated zone rules on 409/422.
66    pub details: Option<serde_json::Value>,
67}
68
69impl ApiError {
70    /// For [`ErrorCode::ManagedRecord`] and [`ErrorCode::ManagedCertificate`]: who keeps
71    /// the resource (`Redirect`, `Acme`) and the name it is kept for (the redirect's host
72    /// name). `None` for other errors.
73    pub fn managed(&self) -> Option<(&str, &str)> {
74        if !matches!(
75            self.code,
76            ErrorCode::ManagedRecord | ErrorCode::ManagedCertificate
77        ) {
78            return None;
79        }
80        let details = self.details.as_ref()?;
81        let managed_by = details.get("managed_by")?.as_str()?;
82        let name = details.get("name")?.as_str()?;
83        Some((managed_by, name))
84    }
85
86    /// For [`ErrorCode::ImportFailed`]: the import report Core sends in `details` (which
87    /// records were refused and why). `None` for other errors, or if `details` does not
88    /// have the report's shape.
89    pub fn import_report(&self) -> Option<ImportReport> {
90        if self.code != ErrorCode::ImportFailed {
91            return None;
92        }
93        serde_json::from_value(self.details.clone()?).ok()
94    }
95}
96
97/// `error.code` values Core sends (`../core/src/api/errors.ts`, `auth.ts`).
98/// Unknown codes are kept as [`ErrorCode::Other`] so a newer Core never breaks parsing.
99///
100/// Non-exhaustive: new Core codes get their own variant in later SDK versions, so a
101/// `match` needs a wildcard arm.
102#[derive(Debug, Clone, PartialEq, Eq)]
103#[non_exhaustive]
104pub enum ErrorCode {
105    /// 401: no credentials were sent (the client was built without an API key).
106    Unauthenticated,
107    /// 401: the API key is unknown, was replaced or was revoked.
108    InvalidApiKey,
109    /// 401: the bearer token was taken as a JWT and rejected. Core takes anything without
110    /// the `dz_`/`du_` prefix for a JWT, so this usually means a key Core did not issue
111    /// (see [`ApiKeyKind::of`](crate::ApiKeyKind::of)).
112    InvalidToken,
113    /// 403: the key may not do this (wrong zone, not the owner, missing scope).
114    Forbidden,
115    /// 403: the zone or client identity is suspended by an Admin (itself or through its
116    /// owner). Reads still work, and so does deleting it; for a zone also removing a
117    /// member and revoking its key.
118    Suspended,
119    /// 404: the resource does not exist, or is not visible to the key.
120    NotFound,
121    /// 409: already exists, still referenced, or a zone rule is violated (see
122    /// [`ApiError::details`]).
123    Conflict,
124    /// 409: the record value is kept by Core for a redirect or a certificate issuance
125    /// (`RecordValue::managed_by`), or a user A/AAAA/CNAME would go where a redirect is.
126    /// [`ApiError::details`] is `{managed_by, name}`; change the redirect instead.
127    ManagedRecord,
128    /// 409: the certificate was issued automatically for a redirect (`source: Acme`) and
129    /// cannot be replaced or deleted directly. [`ApiError::details`] is
130    /// `{managed_by, name}` with the redirect's name; change the redirect's `tls` instead.
131    ManagedCertificate,
132    /// 422: the input failed validation; [`ApiError::details`] lists the issues.
133    Validation,
134    /// 422: a zone import refused some records, so nothing was written;
135    /// [`ApiError::import_report`] has the full report.
136    ImportFailed,
137    /// 422: zone discovery found no usable nameserver (typically the delegation already
138    /// points to Dinio: give the previous provider's nameservers).
139    DiscoveryFailed,
140    /// 413: the uploaded file is too large (zone import: 2 MiB).
141    PayloadTooLarge,
142    /// 503: this Core runs without the audit log.
143    AuditDisabled,
144    /// 503: this Core runs without telemetry, so there are no statistics.
145    TelemetryDisabled,
146    /// 400: the request body is not valid JSON.
147    BadRequest,
148    /// 500: an unexpected error inside Core.
149    Internal,
150    /// A code this SDK version does not know yet, kept verbatim.
151    Other(String),
152}
153
154impl ErrorCode {
155    /// Parses Core's `error.code`; unknown codes become [`ErrorCode::Other`].
156    pub fn from_wire(code: &str) -> Self {
157        match code {
158            "UNAUTHENTICATED" => Self::Unauthenticated,
159            "INVALID_API_KEY" => Self::InvalidApiKey,
160            "INVALID_TOKEN" => Self::InvalidToken,
161            "FORBIDDEN" => Self::Forbidden,
162            "SUSPENDED" => Self::Suspended,
163            "NOT_FOUND" => Self::NotFound,
164            "CONFLICT" => Self::Conflict,
165            "MANAGED_RECORD" => Self::ManagedRecord,
166            "MANAGED_CERTIFICATE" => Self::ManagedCertificate,
167            "VALIDATION" => Self::Validation,
168            "IMPORT_FAILED" => Self::ImportFailed,
169            "DISCOVERY_FAILED" => Self::DiscoveryFailed,
170            "PAYLOAD_TOO_LARGE" => Self::PayloadTooLarge,
171            "AUDIT_DISABLED" => Self::AuditDisabled,
172            "TELEMETRY_DISABLED" => Self::TelemetryDisabled,
173            "BAD_REQUEST" => Self::BadRequest,
174            "INTERNAL" => Self::Internal,
175            other => Self::Other(other.to_owned()),
176        }
177    }
178
179    /// The code as Core sends it (`NOT_FOUND`).
180    pub fn as_wire(&self) -> &str {
181        match self {
182            Self::Unauthenticated => "UNAUTHENTICATED",
183            Self::InvalidApiKey => "INVALID_API_KEY",
184            Self::InvalidToken => "INVALID_TOKEN",
185            Self::Forbidden => "FORBIDDEN",
186            Self::Suspended => "SUSPENDED",
187            Self::NotFound => "NOT_FOUND",
188            Self::Conflict => "CONFLICT",
189            Self::ManagedRecord => "MANAGED_RECORD",
190            Self::ManagedCertificate => "MANAGED_CERTIFICATE",
191            Self::Validation => "VALIDATION",
192            Self::ImportFailed => "IMPORT_FAILED",
193            Self::DiscoveryFailed => "DISCOVERY_FAILED",
194            Self::PayloadTooLarge => "PAYLOAD_TOO_LARGE",
195            Self::AuditDisabled => "AUDIT_DISABLED",
196            Self::TelemetryDisabled => "TELEMETRY_DISABLED",
197            Self::BadRequest => "BAD_REQUEST",
198            Self::Internal => "INTERNAL",
199            Self::Other(code) => code,
200        }
201    }
202}
203
204impl fmt::Display for ErrorCode {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.write_str(self.as_wire())
207    }
208}
209
210/// reqwest's own message is generic ("error sending request"); the cause that matters
211/// (connection refused, certificate, timeout) is further down the source chain.
212fn chain(err: &dyn std::error::Error) -> String {
213    let mut text = err.to_string();
214    let mut source = err.source();
215    while let Some(cause) = source {
216        let cause_text = cause.to_string();
217        if !text.contains(&cause_text) {
218            text.push_str(": ");
219            text.push_str(&cause_text);
220        }
221        source = cause.source();
222    }
223    text
224}
225
226/// `Result` with the SDK's [`Error`].
227pub type Result<T> = std::result::Result<T, Error>;