use serde_json::Value;
#[derive(Debug, Clone)]
pub struct Attempt {
pub status: u16,
pub body: Option<Value>,
pub retry_after_header: Option<u64>,
pub network_error: Option<String>,
}
impl Attempt {
pub fn network(error: impl Into<String>) -> Self {
Attempt {
status: 0,
body: None,
retry_after_header: None,
network_error: Some(error.into()),
}
}
pub fn is_ok_true(&self) -> bool {
self.status == 200
&& self.body.as_ref().and_then(|b| b.get("ok")).and_then(Value::as_bool) == Some(true)
}
pub fn message_id(&self) -> Option<i64> {
self.body.as_ref()?.get("result")?.get("message_id")?.as_i64()
}
pub fn description(&self) -> Option<String> {
self.body.as_ref()?.get("description")?.as_str().map(str::to_string)
}
pub fn retry_after(&self) -> u64 {
let from_body = self
.body
.as_ref()
.and_then(|b| b.get("parameters"))
.and_then(|p| p.get("retry_after"))
.and_then(Value::as_u64);
from_body.or(self.retry_after_header).unwrap_or(1)
}
pub fn error_text(&self) -> String {
if let Some(net) = &self.network_error {
return format!("network error ({net})");
}
match (self.status, self.description()) {
(200, Some(d)) => d,
(200, None) => "200 ok=false".to_string(),
(s, Some(d)) if (500..600).contains(&s) => format!("HTTP {s}: {d}"),
(s, Some(d)) => {
let _ = s;
d
}
(s, None) => format!("HTTP {s}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Disposition {
Success,
Retry(RetryReason),
Terminal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetryReason {
RateLimited,
Transient,
}
pub fn classify(attempt: &Attempt) -> Disposition {
if attempt.network_error.is_some() {
return Disposition::Retry(RetryReason::Transient);
}
match attempt.status {
200 if attempt.is_ok_true() => Disposition::Success,
200 => Disposition::Retry(RetryReason::Transient),
429 => Disposition::Retry(RetryReason::RateLimited),
s if (500..600).contains(&s) => Disposition::Retry(RetryReason::Transient),
s if (400..500).contains(&s) => Disposition::Terminal,
_ => Disposition::Retry(RetryReason::Transient),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn with_body(status: u16, body: Value) -> Attempt {
Attempt { status, body: Some(body), retry_after_header: None, network_error: None }
}
#[test]
fn ok_true_is_success_and_carries_message_id() {
let a = with_body(200, json!({"ok": true, "result": {"message_id": 4242}}));
assert_eq!(classify(&a), Disposition::Success);
assert_eq!(a.message_id(), Some(4242));
}
#[test]
fn ok_false_at_200_is_retried() {
let a = with_body(200, json!({"ok": false, "description": "nope"}));
assert_eq!(classify(&a), Disposition::Retry(RetryReason::Transient));
assert_eq!(a.error_text(), "nope");
}
#[test]
fn four_xx_other_than_429_is_terminal() {
for status in [400u16, 401, 403, 404] {
let a = with_body(status, json!({"ok": false, "description": "bad"}));
assert_eq!(classify(&a), Disposition::Terminal, "status {status}");
assert_eq!(a.error_text(), "bad");
}
}
#[test]
fn five_xx_is_transient_and_keeps_the_status_in_the_message() {
let a = with_body(503, json!({"ok": false, "description": "busy"}));
assert_eq!(classify(&a), Disposition::Retry(RetryReason::Transient));
assert_eq!(a.error_text(), "HTTP 503: busy");
}
#[test]
fn retry_after_prefers_body_then_header_then_one() {
let mut a = with_body(429, json!({"parameters": {"retry_after": 7}}));
a.retry_after_header = Some(30);
assert_eq!(a.retry_after(), 7);
let mut b = with_body(429, json!({"ok": false}));
b.retry_after_header = Some(30);
assert_eq!(b.retry_after(), 30);
let c = with_body(429, json!({"ok": false}));
assert_eq!(c.retry_after(), 1);
}
#[test]
fn network_failures_are_transient_and_report_zero() {
let a = Attempt::network("connection reset");
assert_eq!(a.status, 0);
assert_eq!(classify(&a), Disposition::Retry(RetryReason::Transient));
assert_eq!(a.error_text(), "network error (connection reset)");
}
}