// Cargo.toml:
// reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
// tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
// serde = { version = "1", features = ["derive"] }
// serde_json = { version = "1", features = ["arbitrary_precision"] }
// rust_decimal = { version = "1", features = ["serde-with-arbitrary-precision"] }
// uuid = { version = "1", features = ["v4"] }
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
// arbitrary_precision keeps the JSON number text intact through parsing;
// Decimal parses it exactly. Never round-trip these through f64.
#[derive(Deserialize)]
pub struct Decision {
pub allowed: bool,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub credits_used: Option<rust_decimal::Decimal>,
#[serde(default)]
pub credits_remaining: Option<rust_decimal::Decimal>,
}
pub struct Client {
http: reqwest::Client,
base_url: String,
api_key: String,
keys: Mutex<HashMap<String, String>>, // prod: your own durable store
}
impl Client {
pub fn from_env() -> Self {
Self {
http: reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap(),
base_url: std::env::var("QUOTAMINT_URL").expect("QUOTAMINT_URL"),
api_key: std::env::var("QUOTAMINT_API_KEY").expect("QUOTAMINT_API_KEY"),
keys: Mutex::new(HashMap::new()),
}
}
fn key_for(&self, operation_id: &str) -> String {
let mut keys = self.keys.lock().unwrap();
keys.entry(operation_id.to_string())
.or_insert_with(|| format!("op_{}", uuid::Uuid::new_v4()))
.clone()
}
/// Consume one billable unit. A denied decision is Ok(Decision) with
/// allowed == false — it is a product outcome, not a transport failure.
pub async fn consume(
&self,
customer_id: &str,
feature: &str,
operation_id: &str,
quantity: i64,
) -> Result<Decision, Box<dyn std::error::Error>> {
let idempotency_key = self.key_for(operation_id);
let body = serde_json::json!({
"customerId": customer_id,
"feature": feature,
"quantity": quantity,
"idempotencyKey": idempotency_key,
});
for attempt in 0..4 {
if attempt > 0 {
tokio::time::sleep(Duration::from_millis(500 * 2u64.pow(attempt as u32))).await;
}
let result = self
.http
.post(format!("{}/v1/consume", self.base_url))
.bearer_auth(&self.api_key)
.header("X-Request-ID", uuid::Uuid::new_v4().to_string())
.json(&body)
.send()
.await;
let resp = match result {
Ok(r) => r,
Err(err) => {
// Network error or timeout: outcome unknown, same key.
eprintln!("consume attempt {attempt}: {err}; retrying same key");
continue;
}
};
match resp.status().as_u16() {
500 | 503 => continue, // same key: replay-safe
429 => {
let ra = resp
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0);
if ra > 0 && attempt < 3 {
tokio::time::sleep(Duration::from_secs(ra)).await;
continue;
}
// No Retry-After: the workspace hit its monthly event
// allowance. Retrying cannot help until the plan changes.
return Err("usage limit reached".into());
}
s if (200..300).contains(&s) => return Ok(resp.json::<Decision>().await?),
s => return Err(format!("consume failed: {s}").into()),
}
}
Err("consume did not settle after retries".into())
}
}