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}