Skip to main content

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}