tailscale_rest/error.rs
1//! What can go wrong between here and the control plane.
2//!
3//! This crate does not know about MCP error codes, so the variants are named
4//! for what happened rather than for what a caller should be told. The server
5//! crate maps them; keeping the mapping there is what lets this crate be used
6//! without it.
7
8use std::path::PathBuf;
9use std::time::Duration;
10
11/// A call that did not produce the answer it was asked for.
12#[derive(Debug, thiserror::Error)]
13pub enum ApiError {
14 /// The control plane answered, and the answer was not a success.
15 #[error("{request} failed with {status}: {message}")]
16 Status {
17 /// The method and path, for saying which call this was.
18 request: String,
19 status: u16,
20 /// The API's own `message` field where it sent one, the body where it
21 /// did not, and the status's reason where the body was empty.
22 message: String,
23 /// What the server asked us to wait, from `Retry-After`.
24 retry_after: Option<Duration>,
25 },
26
27 /// The request never became a response: no route, refused, TLS, a reset.
28 #[error("{request} could not be sent: {source}")]
29 Transport {
30 request: String,
31 #[source]
32 source: reqwest::Error,
33 },
34
35 /// The call used up the budget it was given, across every attempt.
36 #[error("{request} did not finish within {}s", budget.as_secs())]
37 Timeout { request: String, budget: Duration },
38
39 /// The body is larger than this server will hold in memory.
40 #[error("{request} answered with more than {cap} bytes")]
41 TooLarge { request: String, cap: usize },
42
43 /// The body arrived and is not what it should be.
44 #[error("{request} answered with a body this server could not read: {source}")]
45 Malformed {
46 request: String,
47 #[source]
48 source: serde_json::Error,
49 },
50
51 /// A token could not be minted, so no call can be made at all.
52 #[error("the control-plane credential could not be exchanged for a token: {0}")]
53 Token(String),
54
55 /// The federated identity's JWT could not be read from disk.
56 #[error("the federated identity file {} could not be read: {source}", path.display())]
57 JwtFile {
58 path: PathBuf,
59 #[source]
60 source: std::io::Error,
61 },
62
63 /// The client was built wrong. Never depends on the network, so it is
64 /// worth telling apart from everything above.
65 #[error("the control-plane client is misconfigured: {0}")]
66 Config(String),
67}
68
69impl ApiError {
70 /// The status the control plane sent, where there was one.
71 ///
72 /// The server crate turns this into the tool error's `status` field, which
73 /// is the number a caller needs to look the failure up in Tailscale's own
74 /// documentation.
75 pub const fn status(&self) -> Option<u16> {
76 match self {
77 Self::Status { status, .. } => Some(*status),
78 _ => None,
79 }
80 }
81
82 /// Whether asking again could plausibly work.
83 ///
84 /// This is about the failure, not about the request: whether *this* call
85 /// may be sent twice is `Idempotence`, and both have to agree before
86 /// anything is retried.
87 pub const fn is_transient(&self) -> bool {
88 match self {
89 Self::Status { status, .. } => matches!(status, 429 | 500 | 502 | 503 | 504),
90 // A request that never reached a response was not acted on.
91 Self::Transport { .. } => true,
92 _ => false,
93 }
94 }
95}
96
97/// What to tell a caller about a failure, from the status and the body.
98///
99/// The API answers a failure with `{"message": "..."}` most of the time and
100/// with something else the rest of it, so all three fallbacks are needed: the
101/// field, then whatever the body says, then the status's own reason for a body
102/// that is empty.
103pub(crate) fn describe(status: reqwest::StatusCode, body: &str) -> String {
104 let body = body.trim();
105 if let Ok(serde_json::Value::Object(fields)) = serde_json::from_str::<serde_json::Value>(body)
106 && let Some(serde_json::Value::String(message)) = fields.get("message")
107 && !message.trim().is_empty()
108 {
109 return message.trim().to_owned();
110 }
111 if body.is_empty() {
112 return status
113 .canonical_reason()
114 .unwrap_or("no reason given")
115 .to_owned();
116 }
117 body.to_owned()
118}
119
120/// Whether a request may be sent a second time.
121///
122/// HTTP's own answer is the method, and it is the answer used here: `GET`,
123/// `HEAD`, `PUT` and `DELETE` are defined to have the same effect done once or
124/// done twice, and `POST` and `PATCH` are not. Minting an auth key is a `POST`,
125/// and a retried mint is a second key nobody asked for and nobody sees.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub(crate) enum Idempotence {
128 /// Repeating it is defined to be the same as doing it once.
129 Repeatable,
130 /// Repeating it may do the thing twice.
131 Once,
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 fn status(status: u16) -> ApiError {
139 ApiError::Status {
140 request: "GET /api/v2/tailnet/-/devices".to_owned(),
141 status,
142 message: "nope".to_owned(),
143 retry_after: None,
144 }
145 }
146
147 #[test]
148 fn the_statuses_worth_asking_again_about_are_the_ones_that_mean_later() {
149 for code in [429, 500, 502, 503, 504] {
150 assert!(status(code).is_transient(), "{code} should be transient");
151 }
152 // A refusal, a bad request or a missing thing will say the same next
153 // time; asking again is a second failure and a second wait.
154 for code in [400, 401, 403, 404, 409, 412, 501] {
155 assert!(!status(code).is_transient(), "{code} should be permanent");
156 }
157 }
158
159 #[test]
160 fn a_failure_is_described_from_the_field_the_api_uses() {
161 let reason = |body| describe(reqwest::StatusCode::BAD_REQUEST, body);
162 assert_eq!(
163 reason(r#"{"message": "invalid tailnet"}"#),
164 "invalid tailnet"
165 );
166 // Not every failure is that shape, and the body still says more than
167 // the status does.
168 assert_eq!(reason("plain trouble"), "plain trouble");
169 assert_eq!(reason(r#"{"error": "nope"}"#), r#"{"error": "nope"}"#);
170 // An empty body leaves only the status.
171 assert_eq!(reason(" "), "Bad Request");
172 }
173
174 #[test]
175 fn only_a_status_carries_a_status() {
176 assert_eq!(status(429).status(), Some(429));
177 assert_eq!(ApiError::Token("no".to_owned()).status(), None);
178 }
179}