Add retry with backoff, and narrow the untyped responses

Retry:
- `RetryPolicy` (3 retries, full-jitter exponential backoff) applied in a
  new `execute` dispatch chokepoint, configurable via
  `with_retry_policy`. `Retry-After` is honoured, and one longer than
  `max_delay` is surfaced rather than slept through.
- What may be replayed depends on the method, not just the status. A 429
  means the server refused the request without processing it, so any
  method is safe. A 5xx or timeout gives no such guarantee, so only
  idempotent methods are retried: this is a billing API, and replaying a
  POST the server had already applied would duplicate an invoice,
  payment, or email.

Response types:
- `statics`, `search`, and `health_check` return `Statics`,
  `SearchResults`, and `HealthCheck` instead of being generic.
  `HealthCheck` covers both deployments — hosted returns a bare message
  rather than a report — and `SearchResults` covers both the
  Elasticsearch and non-Elasticsearch bucket sets.
- Each keeps the `extra` convention, and `statics_as` / `search_as` /
  `health_check_as` remain for pinning shapes left untyped. Nested config
  blobs (gateways, templates, extensions) stay `Value`, same reasoning as
  `Company::settings`.
- `send_email` previously decoded the response as a bare body, but the
  controller returns `itemResponse(...)` — a `data`-wrapped entity. It now
  unwraps, so `T` is the entity. Still generic, since the endpoint serves
  invoices, quotes, credits, and purchase orders.
- `chart` stays generic on purpose: the eleven charts share no response
  shape.
- Adds `id_as_string`, since `/statics` returns `payment_types.id` as a
  string and the other tables return numbers.

Adds CHANGELOG.md (Keep a Changelog), and a CLAUDE.md note to keep it
current. 17 new tests, 43 in client_requests.rs total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 18:18:12 -04:00
co-authored by Claude Opus 5
parent 9976249210
commit 23c660d2f9
14 changed files with 1179 additions and 51 deletions
+161 -38
View File
@@ -8,11 +8,12 @@ use crate::error::{ApiErrorBody, Error, Result};
use crate::models::{
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client as ClientModel,
ClientGatewayToken, Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense,
ExpenseCategory, GroupSetting, Invoice, ItemResponse, ListParams, ListResponse, Location,
Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
RecurringInvoice, RecurringQuote, Subscription, SystemLog, Tag, Task, TaskScheduler,
TaskStatus, TaxRate, User, Vendor, Webhook,
ExpenseCategory, GroupSetting, HealthCheck, Invoice, ItemResponse, ListParams, ListResponse,
Location, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SystemLog, Tag, Task,
TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
};
use crate::retry::{self, RetryPolicy};
/// API root of the hosted Invoice Ninja app.
pub const HOSTED_BASE_URL: &str = "https://invoicing.co/api/v1";
@@ -73,6 +74,7 @@ pub struct InvoiceNinjaClient {
/// in the derived `Debug`.
api_password: Option<header::HeaderValue>,
timeout: Duration,
retry: RetryPolicy,
}
impl InvoiceNinjaClient {
@@ -121,9 +123,19 @@ impl InvoiceNinjaClient {
base_url,
api_password: None,
timeout: DEFAULT_TIMEOUT,
retry: RetryPolicy::default(),
})
}
/// Replaces the retry policy, which defaults to
/// [`RetryPolicy::default`] (3 retries with full-jitter backoff).
///
/// Pass [`RetryPolicy::none`] to attempt every request exactly once.
pub fn with_retry_policy(mut self, retry: RetryPolicy) -> Self {
self.retry = retry;
self
}
/// Overrides the per-request deadline, which defaults to
/// [`DEFAULT_TIMEOUT`].
///
@@ -173,6 +185,63 @@ impl InvoiceNinjaClient {
}
}
/// Dispatches a built request, retrying transient failures per the
/// configured [`RetryPolicy`].
///
/// Every helper goes through here, so retrying is uniform. The request is
/// cloned before each attempt; a body that cannot be cloned (none of ours
/// today, since all payloads are in-memory JSON) simply gets one attempt.
///
/// Note what is *not* retried: a `POST` that fails with a 5xx or times
/// out is surfaced, because the server may already have applied it and a
/// replay would duplicate an invoice, payment, or email. See
/// [`crate::retry`] for the full rules.
async fn execute(&self, builder: reqwest::RequestBuilder) -> Result<reqwest::Response> {
let mut request = builder.build()?;
let method = request.method().clone();
let mut attempt = 0u32;
loop {
let replay = if attempt < self.retry.max_retries {
request.try_clone()
} else {
None
};
let delay = match self.http.execute(request).await {
Ok(response) => {
let Some(next) =
replay.filter(|_| retry::status_is_retryable(response.status(), &method))
else {
return Ok(response);
};
// A server-supplied Retry-After wins over our backoff,
// but one longer than we are willing to wait is returned
// 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 {
return Ok(response);
}
request = next;
delay
}
Err(e) => {
let Some(next) =
replay.filter(|_| retry::transport_error_is_retryable(&e, &method))
else {
return Err(e.into());
};
request = next;
self.retry.backoff(attempt)
}
};
tokio::time::sleep(delay).await;
attempt += 1;
}
}
/// Builds a client pointed at the hosted app at `https://invoicing.co`.
///
/// Equivalent to [`new`](Self::new) with [`HOSTED_BASE_URL`]. Prefer this
@@ -218,10 +287,11 @@ impl InvoiceNinjaClient {
params: &ListParams,
) -> Result<Vec<T>> {
let response = self
.req(Method::GET, path)
.query(params)
.query(&params.custom)
.send()
.execute(
self.req(Method::GET, path)
.query(params)
.query(&params.custom),
)
.await?;
let body: ListResponse<T> = Self::decode(response).await?;
Ok(body.data)
@@ -272,10 +342,11 @@ impl InvoiceNinjaClient {
loop {
params.page = Some(page);
let response = self
.req(Method::GET, path)
.query(&params)
.query(&params.custom)
.send()
.execute(
self.req(Method::GET, path)
.query(&params)
.query(&params.custom),
)
.await?;
let body: ListResponse<T> = Self::decode(response).await?;
let got = body.data.len() as u64;
@@ -315,7 +386,7 @@ impl InvoiceNinjaClient {
}
async fn get_one<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let response = self.req(Method::GET, path).send().await?;
let response = self.execute(self.req(Method::GET, path)).await?;
let body: ItemResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
@@ -325,7 +396,9 @@ impl InvoiceNinjaClient {
path: &str,
payload: &B,
) -> Result<T> {
let response = self.req(Method::POST, path).json(payload).send().await?;
let response = self
.execute(self.req(Method::POST, path).json(payload))
.await?;
let body: ItemResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
@@ -335,13 +408,15 @@ impl InvoiceNinjaClient {
path: &str,
payload: &B,
) -> Result<T> {
let response = self.req(Method::PUT, path).json(payload).send().await?;
let response = self
.execute(self.req(Method::PUT, path).json(payload))
.await?;
let body: ItemResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
async fn delete_one(&self, path: &str) -> Result<()> {
let response = self.req(Method::DELETE, path).send().await?;
let response = self.execute(self.req(Method::DELETE, path)).await?;
let status = response.status();
let bytes = response.bytes().await?;
if status.is_success() {
@@ -356,7 +431,9 @@ impl InvoiceNinjaClient {
path: &str,
payload: &B,
) -> Result<Vec<T>> {
let response = self.req(Method::POST, path).json(payload).send().await?;
let response = self
.execute(self.req(Method::POST, path).json(payload))
.await?;
let body: ListResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
@@ -364,7 +441,7 @@ impl InvoiceNinjaClient {
/// Returns the raw response body. Used for endpoints that do not answer
/// with JSON — PDF streams (`application/pdf`) and CSV report exports.
async fn get_raw(&self, path: &str) -> Result<Vec<u8>> {
let response = self.req(Method::GET, path).send().await?;
let response = self.execute(self.req(Method::GET, path)).await?;
let status = response.status();
let bytes = response.bytes().await?;
if status.is_success() {
@@ -375,7 +452,9 @@ impl InvoiceNinjaClient {
}
async fn post_raw<B: Serialize>(&self, path: &str, payload: &B) -> Result<Vec<u8>> {
let response = self.req(Method::POST, path).json(payload).send().await?;
let response = self
.execute(self.req(Method::POST, path).json(payload))
.await?;
let status = response.status();
let bytes = response.bytes().await?;
if status.is_success() {
@@ -388,7 +467,7 @@ impl InvoiceNinjaClient {
/// Decodes a response that has no envelope and no stable schema
/// (`/statics`, `/charts/*`, `/search`).
async fn get_unwrapped<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let response = self.req(Method::GET, path).send().await?;
let response = self.execute(self.req(Method::GET, path)).await?;
Self::decode(response).await
}
@@ -397,7 +476,9 @@ impl InvoiceNinjaClient {
path: &str,
payload: &B,
) -> Result<T> {
let response = self.req(Method::POST, path).json(payload).send().await?;
let response = self
.execute(self.req(Method::POST, path).json(payload))
.await?;
Self::decode(response).await
}
@@ -877,8 +958,15 @@ impl InvoiceNinjaClient {
/// Posts to a `/charts/{name}` endpoint, e.g. `totals_v2`,
/// `chart_summary_v2`, `calculated_fields`, `cashflow_forecast`.
///
/// The chart payloads have no stable published schema, so the response is
/// returned untyped.
/// Deliberately still generic. Unlike the other endpoints here, the
/// eleven charts share no common response shape — `totals_v2` answers
/// with per-currency maps, `cashflow_forecast` with a date series,
/// `calculated_fields` with scalars — so there is nothing to narrow to.
/// Pass `serde_json::Value`, or your own struct for the chart you are
/// calling.
///
/// `project_analytics` and `project_burnup` take a project id as a
/// further path segment: `chart("project_burnup/<id>", &body)`.
pub async fn chart<B: Serialize, T: DeserializeOwned>(
&self,
name: &str,
@@ -888,33 +976,58 @@ impl InvoiceNinjaClient {
}
/// Full-text search across entities.
pub async fn search<B: Serialize, T: DeserializeOwned>(&self, body: &B) -> Result<T> {
///
/// Which buckets of [`SearchResults`] are filled depends on whether the
/// server has Elasticsearch enabled; see the type's docs.
pub async fn search<B: Serialize>(&self, body: &B) -> Result<SearchResults> {
self.search_as(body).await
}
/// [`search`](Self::search) decoded into a caller-chosen type.
pub async fn search_as<B: Serialize, T: DeserializeOwned>(&self, body: &B) -> Result<T> {
self.post_unwrapped("search", body).await
}
/// Fetches the static lookup tables.
///
/// The response is a single object keyed by lookup name; the official PHP
/// SDK exposes `currencies`, `industries`, `languages`, `countries`,
/// `banks`, `payment_types`, and `templates`, and the API also returns
/// `timezones`, `date_formats`, `datetime_formats`, `gateways`, and
/// `sizes`.
///
/// Each table has its own field set (currencies carry `symbol` and
/// `precision`, countries carry ISO codes), so there's no shared item
/// type; decode into `serde_json::Value`, or into your own struct
/// covering just the tables you need.
///
/// This is a large and near-constant payload — cache it rather than
/// fetching per request.
pub async fn statics<T: DeserializeOwned>(&self) -> Result<T> {
pub async fn statics(&self) -> Result<Statics> {
self.statics_as().await
}
/// [`statics`](Self::statics) decoded into a caller-chosen type.
///
/// [`Statics`] types the uniform lookup tables and keeps the rest in
/// `extra`; use this to pin tables it leaves untyped, such as `gateways`
/// or `templates`.
pub async fn statics_as<T: DeserializeOwned>(&self) -> Result<T> {
self.get_unwrapped("statics").await
}
/// Queues an email for an entity. The body selects the target, e.g.
/// `{"entity": "invoice", "entity_id": "...", "template": "email_template_invoice"}`.
///
/// Returns the refreshed entity, so `T` is the type the email was sent
/// for — [`Invoice`], [`Quote`], [`Credit`], or [`PurchaseOrder`]:
///
/// ```no_run
/// # async fn run(client: &invoiceninjars::InvoiceNinjaClient) -> invoiceninjars::Result<()> {
/// use invoiceninjars::models::Invoice;
/// use serde_json::json;
///
/// let invoice: Invoice = client
/// .send_email(&json!({ "entity": "invoice", "entity_id": "Wpmbk5ezJn" }))
/// .await?;
/// # Ok(())
/// # }
/// ```
///
/// It stays generic over the entity because the endpoint serves all four,
/// but the `data` envelope is now unwrapped for you — it previously was
/// not, so callers had to model the wrapper themselves.
pub async fn send_email<B: Serialize, T: DeserializeOwned>(&self, body: &B) -> Result<T> {
self.post_unwrapped("emails", body).await
self.post_one("emails", body).await
}
/// Refunds a payment. The body takes `id`, optionally `amount`,
@@ -931,7 +1044,17 @@ impl InvoiceNinjaClient {
}
/// Server-side health check (database, queues, and so on).
pub async fn health_check<T: DeserializeOwned>(&self) -> Result<T> {
///
/// The hosted app does not run the check and answers with a bare
/// message, so most fields of [`HealthCheck`] are `Option` — see its
/// docs.
pub async fn health_check(&self) -> Result<HealthCheck> {
self.health_check_as().await
}
/// [`health_check`](Self::health_check) decoded into a caller-chosen
/// type.
pub async fn health_check_as<T: DeserializeOwned>(&self) -> Result<T> {
self.get_unwrapped("health_check").await
}
}
+3 -1
View File
@@ -17,6 +17,8 @@
mod client;
mod error;
pub mod models;
mod retry;
pub use client::{InvoiceNinjaClient, HOSTED_BASE_URL};
pub use client::{InvoiceNinjaClient, DEFAULT_TIMEOUT, HOSTED_BASE_URL};
pub use error::{ApiErrorBody, Error, Result};
pub use retry::RetryPolicy;
+22
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Deserialize)]
pub struct ListResponse<T> {
@@ -132,6 +133,27 @@ where
Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
}
/// Deserializes an id that may arrive as either a JSON string or a number.
///
/// The `/statics` tables are inconsistent about this: `payment_types` casts
/// its id to a string server-side while the other lookup tables leave theirs
/// as integers. Rather than guess per table — where a wrong choice is a hard
/// decode error that breaks the whole response — both forms are accepted and
/// normalised to `String`.
pub(crate) fn id_as_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
match Option::<Value>::deserialize(deserializer)? {
None | Some(Value::Null) => Ok(String::new()),
Some(Value::String(s)) => Ok(s),
Some(Value::Number(n)) => Ok(n.to_string()),
Some(other) => Err(serde::de::Error::custom(format!(
"expected a string or number id, got {other}"
))),
}
}
/// Body for a bulk action (`POST /{resource}/bulk`).
///
/// The action set varies by resource; `archive`, `restore`, and `delete` work
+79
View File
@@ -0,0 +1,79 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// Response of `GET /health_check`.
///
/// Two different payloads arrive here depending on the deployment. A
/// self-hosted install returns the full `SystemHealth::check()` report
/// modelled below; the hosted app returns only `{"message": "", "errors":
/// []}`, since the check is not meaningful there. Every field defaults, so
/// both decode — test [`system_health`](Self::system_health) rather than
/// assuming the detail fields are populated.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HealthCheck {
/// Overall verdict. Absent on hosted, where no check runs.
#[serde(default)]
pub system_health: Option<bool>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub api_version: String,
#[serde(default)]
pub php_version: Option<PhpVersion>,
/// Required PHP extensions and whether each was found.
#[serde(default)]
pub extensions: Value,
#[serde(default)]
pub env_writable: Option<bool>,
#[serde(default)]
pub simple_db_check: Option<bool>,
#[serde(default)]
pub cache_enabled: Option<bool>,
#[serde(default)]
pub exec: Option<bool>,
#[serde(default)]
pub open_basedir: Option<bool>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub mail_mailer: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub pdf_engine: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub queue: String,
/// Queue depth and the most recent failure, when the queue is readable.
#[serde(default)]
pub queue_data: Value,
#[serde(default)]
pub trailing_slash: Option<bool>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub file_permissions: String,
#[serde(default)]
pub exchange_rate_api_not_configured: Option<bool>,
#[serde(default)]
pub is_docker: Option<bool>,
#[serde(default)]
pub pending_migrations: Option<bool>,
/// Set on the hosted app, which returns a bare message instead of a
/// report.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub message: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// The PHP version sub-report of a health check.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PhpVersion {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub current_php_version: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub minimum_php_version: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub current_php_cli_version: String,
#[serde(default)]
pub is_okay: Option<bool>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub memory_limit: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+6
View File
@@ -12,6 +12,7 @@ mod document;
mod expense;
mod expense_category;
mod group_setting;
mod health;
mod invoice;
mod location;
mod payment;
@@ -21,6 +22,8 @@ mod project;
mod purchase_order;
mod recurring_expense;
mod recurring_invoice;
mod search;
mod statics;
mod subscription;
mod system_log;
mod tag;
@@ -46,6 +49,7 @@ pub use document::Document;
pub use expense::Expense;
pub use expense_category::ExpenseCategory;
pub use group_setting::GroupSetting;
pub use health::{HealthCheck, PhpVersion};
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
pub use location::Location;
pub use payment::{Payment, PaymentInvoice};
@@ -55,6 +59,8 @@ pub use project::Project;
pub use purchase_order::PurchaseOrder;
pub use recurring_expense::RecurringExpense;
pub use recurring_invoice::{RecurringInvoice, RecurringQuote};
pub use search::{SearchHit, SearchResults};
pub use statics::{Currency, DateFormat, StaticItem, Statics};
pub use subscription::Subscription;
pub use system_log::SystemLog;
pub use tag::Tag;
+65
View File
@@ -0,0 +1,65 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// Results of `POST /search`.
///
/// Which buckets are populated depends on how the server is configured: with
/// Elasticsearch enabled the controller fills every entity bucket, and
/// without it only `clients`, `client_contacts`, `invoices`, `projects`, and
/// `settings` are returned. Every field therefore defaults to empty rather
/// than being required, so both shapes decode.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SearchResults {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub clients: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_contacts: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoices: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub quotes: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub credits: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub recurring_invoices: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub purchase_orders: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub expenses: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendors: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_contacts: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub projects: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub tasks: Vec<SearchHit>,
/// Matching settings pages rather than records.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub settings: Vec<SearchHit>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// One search result.
///
/// Every bucket uses the same row shape, so this is not entity-specific —
/// use `id` to fetch the full record through the matching `get_*` method.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SearchHit {
/// Hashed id of the matched record.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
/// Entity kind, as a leading-slash tag like `/client` or `/invoice`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub r#type: String,
/// Front-end route for the record, e.g. `/clients/Wpmbk5ezJn`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub path: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+112
View File
@@ -0,0 +1,112 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// The static lookup tables returned by `GET /statics`.
///
/// This is a large, near-constant payload — cache it rather than fetching it
/// per request.
///
/// Only the tables with a stable, uniform row shape are typed. `gateways`,
/// `templates`, and `bulk_updates` are left as `Value`: they are nested
/// configuration blobs rather than lookup lists, and the same reasoning
/// applies as for [`Company::settings`](super::Company). Anything not named
/// here — including `einvoice_schema` and the self-host-only `license_key` —
/// is preserved in `extra`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Statics {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub currencies: Vec<Currency>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub countries: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub industries: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub languages: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub payment_types: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub sizes: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub timezones: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub date_formats: Vec<DateFormat>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub datetime_formats: Vec<DateFormat>,
/// Payment gateway definitions. A nested configuration blob, left
/// untyped.
#[serde(default)]
pub gateways: Value,
/// Email/PDF template defaults. Left untyped for the same reason.
#[serde(default)]
pub templates: Value,
/// Columns that each entity accepts in a bulk update, keyed by entity.
#[serde(default)]
pub bulk_updates: Value,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// A row in one of the simple lookup tables.
///
/// `name` arrives already translated into the requesting user's locale.
/// Per-table extras — a country's ISO codes, a language's locale, a
/// timezone's UTC offset — stay in `extra`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StaticItem {
/// Normalised to a string: most tables return a number here, but
/// `payment_types` returns a string.
#[serde(default, deserialize_with = "super::common::id_as_string")]
pub id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// A currency, which carries formatting detail the other lookup tables do
/// not.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Currency {
#[serde(default, deserialize_with = "super::common::id_as_string")]
pub id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
/// ISO 4217 code, e.g. `USD`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub code: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub symbol: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub thousand_separator: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub decimal_separator: String,
/// Number of decimal places. `Option` rather than defaulted, since 0 is a
/// meaningful precision (JPY) and must not be confused with "absent".
#[serde(default)]
pub precision: Option<i64>,
#[serde(default)]
pub swap_currency_symbol: Option<bool>,
#[serde(default)]
pub exchange_rate: Option<f64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// A date or datetime format option.
///
/// `format` is the PHP format string; the `format_moment` and `format_dart`
/// variants for other client libraries stay in `extra`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DateFormat {
#[serde(default, deserialize_with = "super::common::id_as_string")]
pub id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub format: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+149
View File
@@ -0,0 +1,149 @@
//! Retry policy for transient failures.
//!
//! The rules here are deliberately conservative about which requests may be
//! replayed. This is a billing API: replaying a `POST` that the server
//! already processed but whose response was lost would create a second
//! invoice or payment, which is far worse than surfacing an error.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use reqwest::{header, Method, StatusCode};
/// How transient failures are retried.
///
/// The default retries up to 3 times with full-jitter exponential backoff
/// starting at 500ms. Use [`RetryPolicy::none`] to disable retrying.
#[derive(Debug, Clone)]
pub struct RetryPolicy {
/// Retries *after* the initial attempt, so 3 means up to 4 requests.
pub max_retries: u32,
/// Backoff for the first retry; doubles each attempt.
pub base_delay: Duration,
/// Ceiling on any single wait, including one asked for by `Retry-After`.
pub max_delay: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(10),
}
}
}
impl RetryPolicy {
/// Retries `max_retries` times with the default delays.
pub fn new(max_retries: u32) -> Self {
Self {
max_retries,
..Self::default()
}
}
/// Disables retrying; every request is attempted exactly once.
pub fn none() -> Self {
Self::new(0)
}
pub fn base_delay(mut self, base_delay: Duration) -> Self {
self.base_delay = base_delay;
self
}
pub fn max_delay(mut self, max_delay: Duration) -> Self {
self.max_delay = max_delay;
self
}
/// Full-jitter exponential backoff: a uniform pick from
/// `[0, min(max_delay, base_delay * 2^attempt)]`.
///
/// Jittering the whole interval rather than a fraction of it is what
/// stops a set of clients that failed together from retrying together.
pub(crate) fn backoff(&self, attempt: u32) -> Duration {
let exponential = self
.base_delay
.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))
}
}
/// Picks a value in `[0, upper]` without pulling in a random-number crate.
///
/// Decorrelating retries is all this needs to do — it is not used for
/// anything requiring unpredictability. The counter is mixed in so that
/// calls landing in the same nanosecond still diverge.
fn pseudo_random_millis(upper: u64) -> u64 {
if upper == 0 {
return 0;
}
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
// splitmix64 finalizer, enough to spread adjacent seeds.
let mut x = nanos ^ seq.wrapping_mul(0x9E37_79B9_7F4A_7C15);
x ^= x >> 30;
x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
x ^= x >> 27;
x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
x % (upper + 1)
}
/// Whether replaying the request can be assumed not to double an effect.
///
/// `POST` is excluded: it creates records, runs bulk actions, issues refunds
/// and sends email, none of which is safe to repeat blindly.
fn is_idempotent(method: &Method) -> bool {
matches!(
*method,
Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
)
}
/// Whether a response status should be retried.
pub(crate) fn status_is_retryable(status: StatusCode, method: &Method) -> bool {
match status.as_u16() {
// Rate limited. The server states it did not process the request, so
// replaying cannot duplicate anything — safe even for POST.
429 => true,
// The request may or may not have been applied before the error: a
// 502 or 504 can mean the response was lost rather than the work
// undone. Only replay when repeating is harmless.
500 | 502 | 503 | 504 => is_idempotent(method),
_ => false,
}
}
/// Whether a transport-level failure should be retried.
///
/// Same reasoning as a 5xx: a timeout gives no evidence about whether the
/// server processed the request.
pub(crate) fn transport_error_is_retryable(error: &reqwest::Error, method: &Method) -> bool {
(error.is_timeout() || error.is_connect()) && is_idempotent(method)
}
/// Reads a `Retry-After` delay, which Invoice Ninja sends on throttled
/// responses.
///
/// Only the delta-seconds form is parsed; the HTTP-date form falls through to
/// normal backoff rather than pulling in a date parser.
pub(crate) fn retry_after(headers: &header::HeaderMap) -> Option<Duration> {
let seconds: u64 = headers
.get(header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse()
.ok()?;
Some(Duration::from_secs(seconds))
}