use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use bytes::Bytes;
use serde_json::json;
use crate::events::EventBus;
use crate::ports::{
ByteStream, Clock, Database, Defer, HttpClient, HttpError, IdGen, NoopDefer, Row, Statement,
UlidIdGen,
};
use crate::retry_after;
use crate::scope::Scope;
use crate::usage::{Consumption, Period, Usage, rfc3339};
pub const EVENT_QUOTA_EXHAUSTED: &str = "budget.quota_exhausted";
pub const EVENT_QUOTA_WARNING: &str = "budget.quota_warning";
const QUOTA_METER: &str = "http-requests";
pub const DEFAULT_BUCKET_TABLE: &str = "http_bucket";
pub const DEFAULT_QUOTA_TABLE: &str = "http_usage";
pub const DEFAULT_ALERT_TABLE: &str = "budget_alerts";
const MAX_PACING_WAITS: u32 = 4;
const QUOTA_WARNING_NUMERATOR: u64 = 4;
const QUOTA_WARNING_DENOMINATOR: u64 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Idempotent(pub bool);
#[derive(Debug, Clone, PartialEq)]
pub struct Budget {
pub key: String,
pub capacity: f64,
pub refill_per_sec: f64,
pub cost: f64,
pub daily_limit: Option<u64>,
}
pub type BudgetRouter = Arc<dyn Fn(&http::Request<Bytes>) -> Option<Budget> + Send + Sync>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryPolicy {
max_retries: u32,
base: Duration,
max_delay: Duration,
max_total: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 2,
base: Duration::from_millis(200),
max_delay: Duration::from_secs(5),
max_total: Duration::from_secs(15),
}
}
}
impl RetryPolicy {
#[must_use]
pub fn max_retries(self) -> u32 {
self.max_retries
}
#[must_use]
pub fn base(self) -> Duration {
self.base
}
#[must_use]
pub fn max_delay(self) -> Duration {
self.max_delay
}
#[must_use]
pub fn max_total(self) -> Duration {
self.max_total
}
#[must_use]
pub fn with_max_retries(mut self, max_retries: u32) -> Self {
self.max_retries = max_retries;
self
}
#[must_use]
pub fn with_base(mut self, base: Duration) -> Self {
self.base = base;
self
}
#[must_use]
pub fn with_max_delay(mut self, max_delay: Duration) -> Self {
self.max_delay = max_delay;
self
}
#[must_use]
pub fn with_max_total(mut self, max_total: Duration) -> Self {
self.max_total = max_total;
self
}
}
pub type Sleeper = Arc<dyn Fn(Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
pub struct BudgetedHttpClient {
inner: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
db: Arc<dyn Database>,
router: BudgetRouter,
retry: RetryPolicy,
events: Option<EventBus>,
defer: Arc<dyn Defer>,
sleeper: Sleeper,
bucket_table: String,
quota_table: String,
alert_table: String,
}
impl BudgetedHttpClient {
#[must_use]
pub fn new(
inner: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
db: Arc<dyn Database>,
router: BudgetRouter,
) -> Self {
Self {
inner,
clock,
db,
router,
retry: RetryPolicy::default(),
events: None,
defer: Arc::new(NoopDefer),
sleeper: default_sleeper(),
bucket_table: DEFAULT_BUCKET_TABLE.to_owned(),
quota_table: DEFAULT_QUOTA_TABLE.to_owned(),
alert_table: DEFAULT_ALERT_TABLE.to_owned(),
}
}
#[must_use]
pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
self.retry = policy;
self
}
#[must_use]
pub fn with_events(mut self, bus: EventBus) -> Self {
self.events = Some(bus);
self
}
#[must_use]
pub fn with_defer(mut self, defer: Arc<dyn Defer>) -> Self {
self.defer = defer;
self
}
#[must_use]
pub fn with_sleeper(mut self, sleeper: Sleeper) -> Self {
self.sleeper = sleeper;
self
}
#[must_use]
pub fn with_bucket_table(mut self, table: impl Into<String>) -> Self {
self.bucket_table = table.into();
self
}
#[must_use]
pub fn with_quota_table(mut self, table: impl Into<String>) -> Self {
self.quota_table = table.into();
self
}
#[must_use]
pub fn with_alert_table(mut self, table: impl Into<String>) -> Self {
self.alert_table = table.into();
self
}
#[must_use]
pub fn create_bucket_table_sql(table: &str) -> String {
format!(
"CREATE TABLE IF NOT EXISTS {table} (\n \
bucket TEXT NOT NULL,\n \
tokens DOUBLE PRECISION NOT NULL,\n \
updated_ms BIGINT NOT NULL,\n \
PRIMARY KEY (bucket)\n);"
)
}
#[must_use]
pub fn create_alert_table_sql(table: &str) -> String {
format!(
"CREATE TABLE IF NOT EXISTS {table} (\n \
subject TEXT NOT NULL,\n \
period TEXT NOT NULL,\n \
kind TEXT NOT NULL,\n \
claimed_ms BIGINT NOT NULL,\n \
PRIMARY KEY (subject, period, kind)\n);"
)
}
async fn spend_daily_quota(&self, budget: &Budget) -> Result<(), HttpError> {
let Some(limit) = budget.daily_limit else {
return Ok(());
};
let now = self.clock.now();
let outcome = Usage::new(&self.quota_table)
.consume(
&*self.db,
&budget.key,
QUOTA_METER,
Period::Day,
now,
1,
Some(limit),
)
.await
.map_err(|err| store_failed(&budget.key, &err))?;
match outcome {
Consumption::Consumed(consumed) => {
self.emit_quota_alert(&budget.key, consumed.used, limit, now)
.await;
Ok(())
}
Consumption::Exhausted(exhausted) => Err(HttpError::BudgetExhausted {
what: budget.key.clone(),
retry_after: exhausted.retry_after,
}),
}
}
async fn emit_quota_alert(&self, key: &str, used: u64, limit: u64, now: time::OffsetDateTime) {
let Some(bus) = self.events.as_ref() else {
return;
};
let (name, kind) = if used >= limit {
(EVENT_QUOTA_EXHAUSTED, "exhausted")
} else if used >= warning_threshold(limit) {
(EVENT_QUOTA_WARNING, "warning")
} else {
return;
};
let claimant = format!("{}:{key}", self.quota_table);
let period = rfc3339(Period::Day.window_at(now).start);
if !self
.claim_quota_alert(&claimant, kind, &period, epoch_millis(now))
.await
{
return;
}
let scope = Scope {
request_id: UlidIdGen.ulid(),
defer: Arc::clone(&self.defer),
span: tracing::info_span!("http.budget", budget = %key, outcome = kind),
};
bus.emit_in(
&scope,
name,
json!({ "subject": key, "used": used, "limit": limit }),
);
}
async fn claim_quota_alert(
&self,
claimant: &str,
kind: &str,
period: &str,
now_ms: i64,
) -> bool {
let insert = || claim_statement(&self.alert_table, claimant, kind, period, now_ms);
match self.db.execute(&insert()).await {
Ok(1) => true,
Ok(_) => false,
Err(_) => {
let created = self
.db
.execute(&Statement::new(BudgetedHttpClient::create_alert_table_sql(
&self.alert_table,
)))
.await
.is_ok();
if !created {
tracing::warn!(
budget = %claimant,
kind,
"quota alert dropped: the claim table is missing and could not be created"
);
return false;
}
matches!(self.db.execute(&insert()).await, Ok(1))
}
}
}
async fn acquire(&self, budget: &Budget) -> Result<(), HttpError> {
let capacity = budget.capacity.max(0.0);
let cost = budget.cost.max(0.0);
let refill_per_sec = budget.refill_per_sec.max(0.0);
let mut waits_left = MAX_PACING_WAITS;
loop {
let now_ms = epoch_millis(self.clock.now());
let affected = self
.db
.execute(&acquire_statement(
&self.bucket_table,
&budget.key,
capacity,
cost,
refill_per_sec,
now_ms,
))
.await
.map_err(|err| store_failed(&budget.key, &err))?;
if affected > 0 {
return Ok(());
}
let (stored, updated_ms) = self.bucket_row(&budget.key, capacity, now_ms).await?;
let refilled = refilled_tokens(stored, updated_ms, now_ms, refill_per_sec, capacity);
let retry_in = pacing_retry_in(refilled, cost, refill_per_sec);
if retry_in > self.retry.max_delay() || waits_left == 0 {
return Err(HttpError::BudgetExhausted {
what: budget.key.clone(),
retry_after: retry_in,
});
}
(self.sleeper)(retry_in).await;
waits_left -= 1;
}
}
async fn bucket_row(
&self,
key: &str,
capacity: f64,
now_ms: i64,
) -> Result<(f64, i64), HttpError> {
let rows = self
.db
.query(&Statement::with_values(
bucket_read_sql(&self.bucket_table),
vec![key.to_owned().into()],
))
.await
.map_err(|err| store_failed(key, &err))?;
Ok(rows
.first()
.and_then(|row| Some((tokens_of(row)?, row.get::<i64>("updated_ms")?)))
.unwrap_or((capacity, now_ms)))
}
async fn exchange_with<B, F, Fut>(
&self,
idempotent: bool,
send_one: F,
) -> Result<http::Response<B>, HttpError>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<http::Response<B>, HttpError>>,
{
let policy = self.retry;
let mut waited = Duration::ZERO;
let mut retries_left = policy.max_retries();
let mut attempt = 0_u32;
loop {
let outcome = send_one().await;
match outcome {
Ok(response) => {
let throttled = matches!(response.status().as_u16(), 429 | 503);
if !throttled || !idempotent || retries_left == 0 {
return Ok(response);
}
let stated = retry_after(response.headers(), &*self.clock);
let Some(delay) = next_delay(policy, waited, attempt, stated) else {
return Ok(response);
};
(self.sleeper)(delay).await;
waited += delay;
retries_left -= 1;
attempt += 1;
}
Err(HttpError::Transport(detail)) if idempotent && retries_left > 0 => {
let Some(delay) = next_delay(policy, waited, attempt, None) else {
return Err(HttpError::Transport(detail));
};
(self.sleeper)(delay).await;
waited += delay;
retries_left -= 1;
attempt += 1;
}
Err(err) => return Err(err),
}
}
}
}
#[async_trait]
impl HttpClient for BudgetedHttpClient {
async fn send(
&self,
request: http::Request<Bytes>,
) -> Result<http::Response<Bytes>, HttpError> {
let Some(budget) = (self.router)(&request) else {
return self.inner.send(request).await;
};
let idempotent = request_is_idempotent(&request);
self.acquire(&budget).await?;
self.spend_daily_quota(&budget).await?;
self.exchange_with(idempotent, || self.inner.send(request.clone()))
.await
}
async fn send_streaming(
&self,
request: http::Request<Bytes>,
) -> Result<http::Response<ByteStream>, HttpError> {
let Some(budget) = (self.router)(&request) else {
return self.inner.send_streaming(request).await;
};
let idempotent = request_is_idempotent(&request);
self.acquire(&budget).await?;
self.spend_daily_quota(&budget).await?;
self.exchange_with(idempotent, || self.inner.send_streaming(request.clone()))
.await
}
}
fn default_sleeper() -> Sleeper {
Arc::new(|after| Box::pin(async move { futures_timer::Delay::new(after).await }))
}
fn default_method_idempotent(method: &http::Method) -> bool {
matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS")
}
fn request_is_idempotent(request: &http::Request<Bytes>) -> bool {
request.extensions().get::<Idempotent>().map_or_else(
|| default_method_idempotent(request.method()),
|idempotent| idempotent.0,
)
}
fn epoch_millis(at: time::OffsetDateTime) -> i64 {
i64::try_from(at.unix_timestamp_nanos().div_euclid(1_000_000)).unwrap_or(i64::MAX)
}
#[expect(
clippy::cast_precision_loss,
reason = "token counts stay far below 2^53, the exact f64 integer range"
)]
fn tokens_of(row: &Row) -> Option<f64> {
row.get::<f64>("tokens")
.or_else(|| row.get::<i64>("tokens").map(|tokens| tokens as f64))
}
#[expect(
clippy::cast_precision_loss,
reason = "millisecond deltas stay far below 2^53"
)]
fn elapsed_seconds(from_ms: i64, to_ms: i64) -> f64 {
(to_ms - from_ms).max(0) as f64 / 1_000.0
}
fn refilled_tokens(
stored: f64,
updated_ms: i64,
now_ms: i64,
refill_per_sec: f64,
capacity: f64,
) -> f64 {
(stored + refill_per_sec * elapsed_seconds(updated_ms, now_ms)).min(capacity)
}
fn pacing_retry_in(refilled: f64, cost: f64, refill_per_sec: f64) -> Duration {
if cost <= refilled {
return Duration::ZERO;
}
if refill_per_sec <= 0.0 {
return Duration::MAX;
}
Duration::try_from_secs_f64((cost - refilled) / refill_per_sec).unwrap_or(Duration::MAX)
}
fn backoff_delay(base: Duration, max_delay: Duration, attempt: u32) -> Duration {
base.saturating_mul(2_u32.saturating_pow(attempt))
.min(max_delay)
}
fn next_delay(
policy: RetryPolicy,
waited: Duration,
attempt: u32,
stated: Option<Duration>,
) -> Option<Duration> {
let remaining = policy.max_total().checked_sub(waited)?;
if remaining.is_zero() {
return None;
}
Some(match stated {
Some(stated) => stated.min(remaining),
None => backoff_delay(policy.base(), policy.max_delay(), attempt).min(remaining),
})
}
#[must_use]
fn warning_threshold(limit: u64) -> u64 {
limit
.saturating_mul(QUOTA_WARNING_NUMERATOR)
.div_ceil(QUOTA_WARNING_DENOMINATOR)
}
fn store_failed(what: &str, err: &crate::DbError) -> HttpError {
HttpError::Transport(format!("budget store for {what} failed: {err}"))
}
fn acquire_sql(table: &str) -> String {
let elapsed =
format!("CASE WHEN ? >= {table}.updated_ms THEN (? - {table}.updated_ms) ELSE 0 END");
format!(
"INSERT INTO {table} (bucket, tokens, updated_ms)\n\
SELECT ?, ? - ?, ? WHERE ? >= ?\n\
ON CONFLICT (bucket) DO UPDATE\n\
SET tokens = CASE\n\
WHEN {table}.tokens + ? * {elapsed} / 1000.0 > ?\n\
THEN ? - ?\n\
ELSE {table}.tokens + ? * {elapsed} / 1000.0 - ?\n\
END,\n\
updated_ms = ?\n\
WHERE {table}.tokens + ? * {elapsed} / 1000.0 >= ?"
)
}
fn bucket_read_sql(table: &str) -> String {
format!("SELECT tokens, updated_ms FROM {table} WHERE bucket = ?")
}
fn claim_statement(
table: &str,
claimant: &str,
kind: &str,
period: &str,
claimed_ms: i64,
) -> Statement {
Statement::with_values(
format!(
"INSERT INTO {table} (subject, period, kind, claimed_ms)\n\
VALUES (?, ?, ?, ?)\n\
ON CONFLICT (subject, period, kind) DO NOTHING"
),
vec![
claimant.to_owned().into(),
period.to_owned().into(),
kind.to_owned().into(),
claimed_ms.into(),
],
)
}
fn acquire_statement(
table: &str,
key: &str,
capacity: f64,
cost: f64,
refill_per_sec: f64,
now_ms: i64,
) -> Statement {
Statement::with_values(
acquire_sql(table),
vec![
key.to_owned().into(), capacity.into(), cost.into(), now_ms.into(), capacity.into(), cost.into(), refill_per_sec.into(), now_ms.into(), now_ms.into(), capacity.into(), capacity.into(), cost.into(), refill_per_sec.into(), now_ms.into(), now_ms.into(), cost.into(), now_ms.into(), refill_per_sec.into(), now_ms.into(), now_ms.into(), cost.into(), ],
)
}
#[cfg(test)]
mod tests {
#![allow(clippy::float_cmp)]
use super::*;
use crate::lint_portable_sql;
#[test]
fn elapsed_seconds_is_the_ms_delta_never_negative() {
assert_eq!(elapsed_seconds(1_000, 4_500), 3.5);
assert_eq!(elapsed_seconds(1_000, 1_000), 0.0);
assert_eq!(elapsed_seconds(5_000, 1_000), 0.0, "backwards clock");
}
#[test]
fn refilled_tokens_clamps_at_capacity_and_ignores_backwards_clocks() {
assert_eq!(refilled_tokens(0.5, 0, 1_000, 1.0, 10.0), 1.5);
assert_eq!(
refilled_tokens(9.0, 0, 10_000, 1.0, 10.0),
10.0,
"the clamp, not 19"
);
assert_eq!(
refilled_tokens(4.0, 5_000, 1_000, 1.0, 10.0),
4.0,
"backwards clock: no un-refill"
);
}
#[test]
fn pacing_retry_in_scales_with_the_missing_tokens() {
assert_eq!(pacing_retry_in(5.0, 1.0, 1.0), Duration::ZERO);
assert_eq!(pacing_retry_in(0.0, 1.0, 0.0), Duration::MAX);
assert_eq!(pacing_retry_in(1.0, 2.0, 0.5), Duration::from_secs(2));
assert_eq!(pacing_retry_in(9.0, 10.0, 4.0), Duration::from_millis(250));
}
#[test]
fn backoff_doubles_then_clamps() {
let (base, max) = (Duration::from_millis(200), Duration::from_secs(5));
assert_eq!(backoff_delay(base, max, 0), Duration::from_millis(200));
assert_eq!(backoff_delay(base, max, 1), Duration::from_millis(400));
assert_eq!(backoff_delay(base, max, 2), Duration::from_millis(800));
assert_eq!(backoff_delay(base, max, 3), Duration::from_millis(1_600));
assert_eq!(
backoff_delay(base, max, 4),
Duration::from_millis(3_200),
"still under the clamp"
);
assert_eq!(
backoff_delay(base, max, 5),
Duration::from_secs(5),
"clamped"
);
assert_eq!(backoff_delay(base, max, u32::MAX), Duration::from_secs(5));
}
#[test]
fn next_delay_honours_the_stated_pause_within_the_total() {
let policy = RetryPolicy::default();
assert_eq!(
next_delay(policy, Duration::ZERO, 0, None),
Some(Duration::from_millis(200))
);
assert_eq!(
next_delay(policy, Duration::ZERO, 0, Some(Duration::from_secs(9))),
Some(Duration::from_secs(9))
);
assert_eq!(
next_delay(
policy,
Duration::from_secs(10),
0,
Some(Duration::from_secs(9))
),
Some(Duration::from_secs(5))
);
assert_eq!(
next_delay(policy, Duration::from_secs(14), 0, None),
Some(Duration::from_millis(200))
);
assert_eq!(
next_delay(policy, Duration::from_secs(14), 4, None),
Some(Duration::from_secs(1))
);
assert_eq!(next_delay(policy, Duration::from_secs(15), 0, None), None);
assert_eq!(
next_delay(policy, Duration::from_secs(16), 0, Some(Duration::ZERO)),
None
);
assert_eq!(
next_delay(policy, Duration::ZERO, 0, Some(Duration::ZERO)),
Some(Duration::ZERO)
);
}
#[test]
fn warning_threshold_is_the_exact_eighty_percent_mark() {
assert_eq!(warning_threshold(10), 8);
assert_eq!(warning_threshold(5), 4);
assert_eq!(
warning_threshold(3),
3,
"ceil(2.4) is 3 — the small-quota case"
);
assert_eq!(warning_threshold(1), 1);
assert_eq!(warning_threshold(10_000), 8_000);
assert_eq!(warning_threshold(u64::MAX), u64::MAX.div_ceil(5));
}
#[test]
fn idempotency_defaults_to_safe_methods_and_the_extension_overrides() {
let request = |method: http::Method| {
http::Request::builder()
.method(method)
.body(Bytes::new())
.expect("test request")
};
assert!(request_is_idempotent(&request(http::Method::GET)));
assert!(request_is_idempotent(&request(http::Method::HEAD)));
assert!(request_is_idempotent(&request(http::Method::OPTIONS)));
assert!(!request_is_idempotent(&request(http::Method::POST)));
assert!(!request_is_idempotent(&request(http::Method::DELETE)));
let mut vouched = request(http::Method::POST);
vouched.extensions_mut().insert(Idempotent(true));
assert!(request_is_idempotent(&vouched), "the caller vouches");
let mut withheld = request(http::Method::GET);
withheld.extensions_mut().insert(Idempotent(false));
assert!(!request_is_idempotent(&withheld), "the caller withholds");
}
#[test]
fn the_bucket_ddl_is_portable_and_names_the_table() {
let sql = BudgetedHttpClient::create_bucket_table_sql("http_bucket");
assert!(sql.contains("CREATE TABLE IF NOT EXISTS http_bucket"));
assert!(sql.contains("tokens DOUBLE PRECISION NOT NULL"));
assert!(sql.contains("PRIMARY KEY (bucket)"));
assert_eq!(lint_portable_sql(&sql), vec![], "DDL must be portable");
let usage = Usage::new("http_usage").create_table_sql();
assert_eq!(
lint_portable_sql(&usage),
vec![],
"the quota DDL already is"
);
let alerts = BudgetedHttpClient::create_alert_table_sql("budget_alerts");
assert!(alerts.contains("CREATE TABLE IF NOT EXISTS budget_alerts"));
assert!(alerts.contains("PRIMARY KEY (subject, period, kind)"));
assert_eq!(
lint_portable_sql(&alerts),
vec![],
"the claim DDL must be portable too"
);
}
#[test]
fn the_alert_claim_inserts_one_guarded_row() {
let statement = claim_statement(
"budget_alerts",
"http_usage:dexscreener",
"exhausted",
"2026-10-09T00:00:00Z",
1_000,
);
assert!(
statement
.sql
.contains("ON CONFLICT (subject, period, kind) DO NOTHING")
);
assert_eq!(statement.values.0.len(), 4);
assert!(
matches!(&statement.values.0[0], sea_query::Value::String(Some(who)) if who.as_str() == "http_usage:dexscreener"),
"the claimant (quota table and key) is the first bind"
);
assert!(
matches!(&statement.values.0[2], sea_query::Value::String(Some(kind)) if kind.as_str() == "exhausted"),
"the kind is the third bind"
);
assert!(
matches!(statement.values.0[3], sea_query::Value::BigInt(Some(v)) if v == 1_000),
"the stamp binds as a big integer"
);
}
#[test]
fn the_acquire_statement_binds_the_seed_and_three_refill_passes() {
let statement = acquire_statement("http_bucket", "dexscreener", 60.0, 1.0, 2.0, 1_000);
assert!(statement.sql.contains("ON CONFLICT (bucket) DO UPDATE"));
assert!(statement.sql.contains("CASE"));
assert!(!statement.sql.contains("MIN("));
assert!(!statement.sql.contains("LEAST("));
assert_eq!(
statement
.sql
.matches("CASE WHEN ? >= http_bucket.updated_ms")
.count(),
3,
"each of the three refill terms clamps the elapsed time"
);
assert_eq!(statement.values.0.len(), 21);
assert!(
matches!(statement.values.0[1], sea_query::Value::Double(Some(v)) if v == 60.0),
"capacity binds as a double"
);
assert!(
matches!(statement.values.0[3], sea_query::Value::BigInt(Some(v)) if v == 1_000),
"the stamp binds as a big integer"
);
assert!(
matches!(&statement.values.0[0], sea_query::Value::String(Some(key)) if key.as_str() == "dexscreener"),
"the key is the first bind"
);
assert!(
matches!(statement.values.0[7], sea_query::Value::BigInt(Some(v)) if v == 1_000),
"the clamp's `when` binds the clock"
);
assert!(
matches!(statement.values.0[8], sea_query::Value::BigInt(Some(v)) if v == 1_000),
"the clamp's `then` binds the clock again"
);
}
#[test]
fn the_bucket_level_reads_as_doubles_and_as_the_integral_shapes_d1_hands_back() {
let row = |value: sea_query::Value| Row::new(vec![("tokens".to_owned(), value)]);
assert_eq!(tokens_of(&row(9.5.into())), Some(9.5), "double, as bound");
assert_eq!(
tokens_of(&row(sea_query::Value::Float(Some(9.5)))),
Some(9.5),
"the single-precision shape some drivers answer with"
);
assert_eq!(
tokens_of(&row(9_i64.into())),
Some(9.0),
"D1's integral numbers: the fallback, not `None`"
);
assert_eq!(
tokens_of(&row(0_i64.into())),
Some(0.0),
"the spent bucket, exactly zero, reads as zero"
);
assert_eq!(
tokens_of(&row(sea_query::Value::Double(None))),
None,
"NULL is still missing"
);
assert_eq!(
tokens_of(&row("9".to_owned().into())),
None,
"a non-numeric column is unrepresentable, as ever"
);
}
#[test]
fn the_bucket_read_targets_one_key() {
assert_eq!(
bucket_read_sql("http_bucket"),
"SELECT tokens, updated_ms FROM http_bucket WHERE bucket = ?"
);
}
#[test]
fn the_retry_policy_defaults_and_builds() {
let policy = RetryPolicy::default();
assert_eq!(policy.max_retries(), 2);
assert_eq!(policy.base(), Duration::from_millis(200));
assert_eq!(policy.max_delay(), Duration::from_secs(5));
assert_eq!(policy.max_total(), Duration::from_secs(15));
let tuned = policy
.with_max_retries(5)
.with_base(Duration::from_millis(50))
.with_max_delay(Duration::from_secs(2))
.with_max_total(Duration::from_secs(30));
assert_eq!(tuned.max_retries(), 5);
assert_eq!(tuned.base(), Duration::from_millis(50));
assert_eq!(tuned.max_delay(), Duration::from_secs(2));
assert_eq!(tuned.max_total(), Duration::from_secs(30));
assert_eq!(policy.max_retries(), 2);
}
#[test]
fn epoch_millis_floors_to_whole_milliseconds() {
let at = |nanos: i64| time::OffsetDateTime::UNIX_EPOCH + time::Duration::nanoseconds(nanos);
assert_eq!(epoch_millis(at(1_500_000)), 1, "1.5 ms floors, not rounds");
assert_eq!(epoch_millis(at(1_999_999)), 1);
assert_eq!(epoch_millis(at(2_000_000)), 2);
assert_eq!(epoch_millis(at(0)), 0);
assert_eq!(
epoch_millis(at(-1_500_000)),
-2,
"floors below the epoch too"
);
}
}