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