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}