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:
+76
-13
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user