Harden error reporting, retry bounds, and credential handling

Second hardening pass, over the code added in 23c660d.

Error reporting:
- Decode failures now carry an excerpt of the body that arrived. The
  serde message alone ("expected value at line 1 column 1") gives no hint
  that the response was an HTML error page from a proxy or a login
  redirect from a wrong base URL. The excerpt is collapsed to one line
  and truncated so a large body cannot become a large error.
- API errors with an empty or message-less body report the status reason
  ("Not Found") rather than rendering blank.
- `Error` is `#[non_exhaustive]`, so future variants are not breaking.

Retry bounds:
- `RetryPolicy::max_elapsed` (120s default) bounds the time one call may
  spend across attempts. The request timeout is per attempt, so the
  previous defaults could spend four minutes on a call that kept timing
  out — a surprise for anyone reading `with_timeout(60)` as a bound on
  the call.
- `backoff` uses saturating arithmetic; a large `max_delay` could
  overflow the jitter span and panic in a debug build.
- Response bodies are drained before a retry so the connection returns to
  the pool instead of the next attempt opening a fresh one.

Credentials:
- Test pinning that neither the token nor the password appears in `Debug`
  output. `reqwest`'s `Client` prints its default headers, so the
  sensitive flag set when building them is the only thing keeping the
  token out of logs, and nothing enforced that.

Also drops `Option::is_none_or`, which raised the minimum toolchain to
1.82 for no benefit over a match.

91 tests, up from 80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 18:33:43 -04:00
co-authored by Claude Opus 5
parent 23c660d2f9
commit 8ff947455e
8 changed files with 412 additions and 21 deletions
+76 -13
View File
@@ -60,6 +60,37 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
Ok(utf8_percent_encode(value, PATH_SEGMENT))
}
/// Renders the start of a response body for an error message.
///
/// Collapsed to one line and truncated, so a stray HTML error page or a
/// multi-megabyte payload cannot turn an error into a wall of text.
fn excerpt(bytes: &[u8]) -> String {
/// Enough to recognise an HTML page, a proxy error, or a short JSON
/// message without quoting a whole document.
const MAX: usize = 200;
let head = &bytes[..bytes.len().min(MAX)];
// Lossy because the body may be truncated mid-character, or not be text
// at all — a PDF arriving where JSON was expected is exactly the kind of
// mix-up this is here to explain.
let text = String::from_utf8_lossy(head);
let mut out: String = text
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let trimmed = out.trim();
if trimmed.len() != out.len() {
out = trimmed.to_string();
}
if out.is_empty() {
return "<empty body>".to_string();
}
if bytes.len() > MAX {
out.push('…');
}
out
}
/// An async client for the Invoice Ninja v5 API.
///
/// `base_url` should point at the API root — note the `/api/v1` suffix, e.g.
@@ -199,14 +230,13 @@ impl InvoiceNinjaClient {
async fn execute(&self, builder: reqwest::RequestBuilder) -> Result<reqwest::Response> {
let mut request = builder.build()?;
let method = request.method().clone();
let started = std::time::Instant::now();
let mut attempt = 0u32;
loop {
let replay = if attempt < self.retry.max_retries {
request.try_clone()
} else {
None
};
let may_retry =
attempt < self.retry.max_retries && self.retry.within_budget(started.elapsed());
let replay = if may_retry { request.try_clone() } else { None };
let delay = match self.http.execute(request).await {
Ok(response) => {
@@ -220,9 +250,15 @@ impl InvoiceNinjaClient {
// as the error instead of slept through.
let delay = retry::retry_after(response.headers())
.unwrap_or_else(|| self.retry.backoff(attempt));
if delay > self.retry.max_delay {
if delay > self.retry.max_delay
|| !self.retry.within_budget(started.elapsed() + delay)
{
return Ok(response);
}
// Drain the body before retrying so the connection can
// return to the pool; dropping it unread makes the next
// attempt open a fresh one.
let _ = response.bytes().await;
request = next;
delay
}
@@ -232,8 +268,12 @@ impl InvoiceNinjaClient {
else {
return Err(e.into());
};
let delay = self.retry.backoff(attempt);
if !self.retry.within_budget(started.elapsed() + delay) {
return Err(e.into());
}
request = next;
self.retry.backoff(attempt)
delay
}
};
@@ -260,24 +300,47 @@ impl InvoiceNinjaClient {
let bytes = response.bytes().await?;
if status.is_success() {
Ok(serde_json::from_slice(&bytes)?)
serde_json::from_slice(&bytes).map_err(|source| Error::Json {
source,
excerpt: excerpt(&bytes),
})
} else {
Err(Self::api_error(status, &bytes))
}
}
fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
let fallback = |message: String| Error::Api {
status,
// An empty body would otherwise render as "api returned 404: ",
// which says less than the status line already did.
message: if message.trim().is_empty() {
status
.canonical_reason()
.unwrap_or("no message in response")
.to_string()
} else {
message
},
errors: Default::default(),
};
match serde_json::from_slice::<ApiErrorBody>(bytes) {
// A body that parses but carries neither a message nor field
// errors is no more useful than an unparseable one.
Ok(body) if body.message.trim().is_empty() && body.errors.is_empty() => {
fallback(String::new())
}
Ok(body) => Error::Api {
status,
message: body.message,
errors: body.errors,
},
Err(_) => Error::Api {
status,
message: String::from_utf8_lossy(bytes).into_owned(),
errors: Default::default(),
},
// Checked on the bytes rather than the excerpt, which
// substitutes a placeholder for an empty body and would read as
// a message.
Err(_) if bytes.iter().all(u8::is_ascii_whitespace) => fallback(String::new()),
Err(_) => fallback(excerpt(bytes)),
}
}
+15 -2
View File
@@ -10,13 +10,26 @@ pub struct ApiErrorBody {
pub errors: HashMap<String, Vec<String>>,
}
/// Marked `#[non_exhaustive]`: matching on it must include a catch-all arm,
/// so that new variants can be added without breaking callers.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("http transport error: {0}")]
Http(#[from] reqwest::Error),
#[error("failed to decode JSON response: {0}")]
Json(#[from] serde_json::Error),
/// The response was not the JSON this endpoint is documented to return.
///
/// Carries an excerpt of what actually arrived. Without it the inner
/// serde message ("expected value at line 1 column 1") gives no clue
/// that the body was, say, an HTML error page from a reverse proxy or a
/// login redirect from a misconfigured base URL.
#[error("failed to decode JSON response: {source}; body began: {excerpt}")]
Json {
#[source]
source: serde_json::Error,
excerpt: String,
},
#[error("api returned {status}: {message}")]
Api {
+34 -2
View File
@@ -22,6 +22,14 @@ pub struct RetryPolicy {
pub base_delay: Duration,
/// Ceiling on any single wait, including one asked for by `Retry-After`.
pub max_delay: Duration,
/// Ceiling on the time spent across *all* attempts for one call.
///
/// This exists because the request timeout is per attempt, not per call:
/// with the defaults, four attempts that each time out would otherwise
/// take four minutes. Once this much has elapsed no further retry
/// starts, and the last error is returned. `None` bounds a call only by
/// `max_retries`.
pub max_elapsed: Option<Duration>,
}
impl Default for RetryPolicy {
@@ -30,6 +38,7 @@ impl Default for RetryPolicy {
max_retries: 3,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(10),
max_elapsed: Some(Duration::from_secs(120)),
}
}
}
@@ -58,6 +67,24 @@ impl RetryPolicy {
self
}
/// Sets the ceiling on total time across all attempts; `None` removes
/// it.
pub fn max_elapsed(mut self, max_elapsed: Option<Duration>) -> Self {
self.max_elapsed = max_elapsed;
self
}
/// Whether another attempt may start after `elapsed` spent so far.
///
/// Spelled as a match rather than `is_none_or`, which would raise the
/// minimum toolchain to 1.82 for no benefit.
pub(crate) fn within_budget(&self, elapsed: Duration) -> bool {
match self.max_elapsed {
Some(budget) => elapsed < budget,
None => true,
}
}
/// Full-jitter exponential backoff: a uniform pick from
/// `[0, min(max_delay, base_delay * 2^attempt)]`.
///
@@ -66,10 +93,13 @@ impl RetryPolicy {
pub(crate) fn backoff(&self, attempt: u32) -> Duration {
let exponential = self
.base_delay
// `checked_shl` yields None past 31 places, and the multiply can
// overflow well before that; either way the cap applies.
.checked_mul(1u32.checked_shl(attempt).unwrap_or(u32::MAX))
.unwrap_or(self.max_delay)
.min(self.max_delay);
Duration::from_millis(pseudo_random_millis(exponential.as_millis() as u64))
let millis = u64::try_from(exponential.as_millis()).unwrap_or(u64::MAX);
Duration::from_millis(pseudo_random_millis(millis))
}
}
@@ -96,7 +126,9 @@ fn pseudo_random_millis(upper: u64) -> u64 {
x ^= x >> 27;
x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
x % (upper + 1)
// Saturating, so an enormous `max_delay` cannot overflow the span and
// panic in a debug build.
x % upper.saturating_add(1)
}
/// Whether replaying the request can be assumed not to double an effect.