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>;