Skip to main content

git_lfs_api/
error.rs

1use std::time::Duration;
2
3use serde::{Deserialize, Serialize};
4
5/// The standard error body returned by the LFS server for non-2xx responses.
6///
7/// Defined by the batch spec § "Response Errors". The same shape is
8/// reused by the locking endpoints.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct ServerError {
11    /// Human-readable error description.
12    pub message: String,
13    /// Server-assigned request identifier, useful for support tickets.
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub request_id: Option<String>,
16    /// URL pointing at server-side documentation for the error.
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub documentation_url: Option<String>,
19}
20
21/// Errors returned by the API client.
22#[derive(Debug, thiserror::Error)]
23pub enum ApiError {
24    /// Network, TLS or connection-level failure.
25    #[error("transport error: {0}")]
26    Transport(#[from] reqwest::Error),
27
28    /// Server returned a non-success HTTP status.
29    ///
30    /// The `Display` impl renders 401 and 403 as `Authorization
31    /// error: <url>` to match upstream's `lfshttp.defaultError`;
32    /// other statuses fall through to a plain server-side message
33    /// when a parseable error body is present, otherwise to a
34    /// generic `server returned status N` line.
35    #[error("{}", format_status(*status, url.as_deref(), body.as_ref()))]
36    Status {
37        /// HTTP status code from the server.
38        status: u16,
39        /// Request URL the server responded to. Embedded in the
40        /// `Display` impl so users can see *which* endpoint failed.
41        url: Option<String>,
42        /// `LFS-Authenticate` response header, mirrored verbatim.
43        /// Only present on 401 responses; signals which auth scheme
44        /// the server wants.
45        lfs_authenticate: Option<String>,
46        /// Parsed LFS error body when the response carried one.
47        body: Option<ServerError>,
48        /// Parsed `Retry-After` response header.
49        ///
50        /// `Some` when the server pinned a wait time the caller
51        /// should honor instead of falling back to exponential
52        /// backoff. Used by the transfer queue's batch retry loop.
53        retry_after: Option<Duration>,
54    },
55
56    /// JSON body did not match the expected schema.
57    #[error("malformed response body: {0}")]
58    Decode(String),
59
60    /// Failed to construct the request URL from the endpoint.
61    #[error("url error: {0}")]
62    Url(#[from] url::ParseError),
63
64    /// `git credential` couldn't supply usable credentials for the
65    /// endpoint.
66    ///
67    /// `detail` carries the underlying helper-side reason
68    /// (e.g. `credential value for path contains newline: …`) when
69    /// available; absent when every helper just returned "I don't know".
70    /// Format mirrors upstream's `creds.FillCreds`.
71    #[error("Git credentials for {url} not found{}", detail.as_deref().map(|d| format!(":\n{d}")).unwrap_or_else(|| ".".into()))]
72    CredentialsNotFound { url: String, detail: Option<String> },
73}
74
75/// Render an [`ApiError::Status`] for the user.
76///
77/// When the response carried a parseable error body, surface its
78/// `message` verbatim — that's what upstream's `lfshttp.ClientError.Error()`
79/// does, and what tests like `t-pre-push` / `t-fetch-refspec` "with
80/// bad ref" grep for ("`Expected ref \"refs/heads/other\", got …`").
81///
82/// Falling back: 401/403 format as `Authorization error: <url>` to
83/// match upstream's `lfshttp.defaultError`, which `t-credentials` and
84/// `t-askpass` grep for verbatim. Everything else gets a plain
85/// `server returned status N` line.
86fn format_status(status: u16, url: Option<&str>, body: Option<&ServerError>) -> String {
87    if let Some(b) = body
88        && !b.message.is_empty()
89    {
90        return b.message.clone();
91    }
92    if matches!(status, 401 | 403)
93        && let Some(u) = url
94    {
95        return format!("Authorization error: {u}");
96    }
97    format!("server returned status {status}")
98}
99
100impl ApiError {
101    /// `true` for 401 responses; caller should resolve credentials and retry.
102    pub fn is_unauthorized(&self) -> bool {
103        matches!(self, ApiError::Status { status: 401, .. })
104    }
105
106    /// `true` for 403 responses; caller lacks permission for this operation.
107    pub fn is_forbidden(&self) -> bool {
108        matches!(self, ApiError::Status { status: 403, .. })
109    }
110
111    /// `true` for 404 responses.
112    pub fn is_not_found(&self) -> bool {
113        matches!(self, ApiError::Status { status: 404, .. })
114    }
115
116    /// `true` for 5xx and 408/429 responses (transient errors a
117    /// caller may want to retry).
118    pub fn is_retryable(&self) -> bool {
119        matches!(
120            self,
121            ApiError::Transport(_)
122                | ApiError::Status {
123                    status: 408 | 429 | 500..=599,
124                    ..
125                }
126        )
127    }
128
129    /// Server-supplied retry delay, if any.
130    ///
131    /// Pulled from the `Retry-After` response header at decode
132    /// time. Mirrors upstream's `errors.NewRetriableLaterError`
133    /// gate; falls back to exponential backoff at the call site
134    /// when `None`.
135    pub fn retry_after(&self) -> Option<Duration> {
136        match self {
137            ApiError::Status { retry_after, .. } => *retry_after,
138            _ => None,
139        }
140    }
141}
142
143/// Parse a `Retry-After` header value.
144///
145/// Accepts the integer-seconds form (`Retry-After: 5`). The
146/// alternate RFC 1123 datetime form isn't supported; callers
147/// requiring it should parse the header themselves.
148///
149/// Returns `None` for missing or unparseable values, signaling
150/// "fall back to exponential backoff" (the same semantic upstream
151/// uses when its helper returns nil).
152pub fn parse_retry_after(value: &str) -> Option<Duration> {
153    let trimmed = value.trim();
154    trimmed.parse::<u64>().ok().map(Duration::from_secs)
155}