usage-meter-poll: Retry-After reads all three HTTP-date forms (finding retry-after-obsolete-date-forms)

RFC 9110 10.2.3 makes a recipient accept IMF-fixdate, RFC 850 and asctime;
the last two read as absent and the poll asked again at the floor. The
two-digit RFC 850 year more than 50 years ahead is the latest past year.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-01 03:17:08 +01:00
commit 481428a7ce
2 changed files with 103 additions and 168 deletions

View file

@ -197,18 +197,69 @@ impl UsageClient {
}
/// A `Retry-After` value as a wait from `now_ms`: delay-seconds, or an
/// HTTP-date (RFC 9110 10.2.3) measured from now, a date already past being a
/// zero wait. `None` when it is neither.
/// HTTP-date (RFC 9110 10.2.3, all three forms) measured from now, a date
/// already past being a zero wait. `None` when it is neither.
fn retry_after(value: &str, now_ms: u64) -> Option<Duration> {
let value = value.trim();
if let Ok(seconds) = value.parse::<u64>() {
return Some(Duration::from_secs(seconds));
}
let date = chrono::DateTime::parse_from_rfc2822(value).ok()?;
let until = u64::try_from(date.timestamp_millis()).ok()?;
let until = http_date_ms(value, now_ms)?;
Some(Duration::from_millis(until.saturating_sub(now_ms)))
}
/// An HTTP-date as Unix milliseconds: IMF-fixdate (RFC 2822 reads it), the
/// obsolete RFC 850 form `Sunday, 06-Nov-94 08:49:37 GMT`, or asctime
/// `Sun Nov 6 08:49:37 1994`. Weekday names are not checked.
fn http_date_ms(value: &str, now_ms: u64) -> Option<u64> {
use chrono::Datelike;
if let Ok(date) = chrono::DateTime::parse_from_rfc2822(value) {
return u64::try_from(date.timestamp_millis()).ok();
}
let fields: Vec<&str> = value.split_whitespace().collect();
let (day, month, year, time) = match fields.as_slice() {
[weekday, date, time, "GMT"] if weekday.ends_with(',') => {
let mut parts = date.split('-');
let (day, month, year) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() || year.len() != 2 {
return None;
}
// A two-digit year more than 50 years ahead is the latest past
// year with those digits.
let now_year =
chrono::DateTime::from_timestamp_millis(i64::try_from(now_ms).ok()?)?.year();
let year = now_year - now_year.rem_euclid(100) + year.parse::<i32>().ok()?;
let year = if year > now_year + 50 {
year - 100
} else {
year
};
(day, month, year, *time)
}
[_, month, day, time, year] => (*day, *month, year.parse::<i32>().ok()?, *time),
_ => return None,
};
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let month = MONTHS
.iter()
.position(|name| name.eq_ignore_ascii_case(month))?;
let mut clock = time.split(':');
let (hour, minute, second) = (clock.next()?, clock.next()?, clock.next()?);
if clock.next().is_some() {
return None;
}
let moment =
chrono::NaiveDate::from_ymd_opt(year, u32::try_from(month + 1).ok()?, day.parse().ok()?)?
.and_hms_opt(
hour.parse().ok()?,
minute.parse().ok()?,
second.parse().ok()?,
)?;
u64::try_from(moment.and_utc().timestamp_millis()).ok()
}
/// The schedule and the thing it drives. Time comes in as an argument so the
/// schedule is testable without waiting.
struct Poller {
@ -796,6 +847,39 @@ mod tests {
assert_eq!(lines.lock().unwrap()[1], State::Polled.line());
}
/// 2026-11-01 07:28:00 UTC, a Sunday.
fn sunday_morning_ms() -> u64 {
chrono::NaiveDate::from_ymd_opt(2026, 11, 1)
.unwrap()
.and_hms_opt(7, 28, 0)
.unwrap()
.and_utc()
.timestamp_millis() as u64
}
#[test]
fn retry_after_reads_all_three_http_date_forms() {
let now = sunday_morning_ms();
for form in [
"Sun, 01 Nov 2026 08:28:00 GMT",
"Sunday, 01-Nov-26 08:28:00 GMT",
"Sun Nov 1 08:28:00 2026",
"Sun Nov 1 08:28:00 2026",
] {
assert_eq!(retry_after(form, now), Some(secs(3600)), "{form}");
}
}
#[test]
fn a_two_digit_year_more_than_fifty_years_ahead_is_the_past() {
let now = sunday_morning_ms();
let on = |yy: &str| retry_after(&format!("Friday, 01-Jan-{yy} 00:00:00 GMT"), now);
assert!(on("76").unwrap() > Duration::ZERO, "2076 is 50 years ahead");
assert_eq!(on("77"), Some(Duration::ZERO), "1977, not 2077");
assert!(on("33").unwrap() > Duration::ZERO, "2033");
assert_eq!(on("94"), Some(Duration::ZERO), "1994");
}
#[test]
fn retry_after_reads_seconds_and_http_dates() {
const DATE: &str = "Wed, 21 Oct 2026 07:28:00 GMT";
@ -808,7 +892,21 @@ mod tests {
assert_eq!(retry_after(DATE, at + 1), Some(Duration::ZERO), "past");
assert_eq!(retry_after("3600", at), Some(secs(3600)));
assert_eq!(retry_after(" 3600 ", at), Some(secs(3600)));
for bad in ["", "soon", "-5", "3600.5", "1, 2", "Wed, 21 Oct 2026"] {
for bad in [
"",
"soon",
"-5",
"3600.5",
"1, 2",
"Wed, 21 Oct 2026",
"Wednesday, 21-Oct-26-1 07:28:00 GMT",
"Wednesday, 21-Oct-2026 07:28:00 GMT",
"Wednesday, 21-Foo-26 07:28:00 GMT",
"Wednesday, 21-Oct-26 07:28 GMT",
"Wed Oct 21 07:28:00:01 2026",
"Wed Oct 32 07:28:00 2026",
"Wed Oct 21 25:28:00 2026",
] {
assert_eq!(retry_after(bad, at), None, "{bad:?}");
}
}

View file

@ -1,163 +0,0 @@
//! `claude-connector serve --config` as a process: it binds, stays up, and
//! starts the usage poll beside the lease listener. Only a spawned binary
//! shows that `serve_config` calls the poll start at all; the poll's own
//! behaviour is tested against fake servers in `usage_poll`.
//!
//! The usage endpoint is a constant of the connector, so this test's poll
//! asks the real host with a made-up token and sees an answer or an
//! unreachable network; either way it logs a `usage poll:` state line, and
//! that line is what is asserted.
#![cfg(unix)]
use std::io::{BufRead, BufReader};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
const TOKEN: &str = "sk-ant-oat01-CANARY-must-never-surface";
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir =
std::env::temp_dir().join(format!("claude-connector-serve-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Kills the served process however the test ends.
struct Served(Child);
impl Drop for Served {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn run(args: &[&str]) {
let made = Command::new(env!("CARGO_BIN_EXE_claude-connector"))
.args(args)
.env_clear()
.output()
.unwrap();
assert!(made.status.success(), "{made:?}");
}
/// A document the connector can serve: a pinned stand-in for the claude
/// binary, a 0700 prompt directory, and the unit's credentials directory.
fn ready_document(scratch: &Scratch) -> (PathBuf, PathBuf) {
let path = |name: &str| scratch.path(name);
let text = |name: &str| path(name).to_string_lossy().into_owned();
std::fs::write(path("claude"), b"not a real claude binary").unwrap();
let pin = hex(&Sha256::digest(b"not a real claude binary"));
for dir in ["work", "home", "prompts", "credentials"] {
std::fs::create_dir(path(dir)).unwrap();
}
std::fs::set_permissions(path("prompts"), std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::write(path("caller.key"), "k".repeat(48)).unwrap();
std::fs::write(path("credentials/claude-token"), TOKEN).unwrap();
let config = text("connector.cc");
run(&[
"config",
"init",
&config,
"--listen",
"127.0.0.1:0",
"--replay-store",
&text("replay.cc"),
"--claude-binary",
&text("claude"),
"--claude-sha256",
&pin,
"--work-dir",
&text("work"),
"--config-dir",
&text("home"),
"--prompt-dir",
&text("prompts"),
"--instance",
"persona-a",
"--token-credential",
"claude-token",
"--handle-secret-credential",
"claude-handle-secret",
"--handle-secret-file",
&text("credentials/claude-handle-secret"),
"--caller-id",
"persona-a-caller",
"--connection-key-file",
&text("caller.key"),
"--connection-key-epoch",
"1",
"--allowed-model",
"claude-haiku-5-5",
]);
(path("connector.cc"), path("credentials"))
}
fn serve(config: &Path, credentials: &Path) -> (Served, mpsc::Receiver<String>) {
let mut child = Command::new(env!("CARGO_BIN_EXE_claude-connector"))
.args(["serve", "--config"])
.arg(config)
.env_clear()
.env("CREDENTIALS_DIRECTORY", credentials)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let stderr = child.stderr.take().unwrap();
let (lines, received) = mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
if lines.send(line).is_err() {
return;
}
}
});
(Served(child), received)
}
#[test]
fn serve_config_starts_the_usage_poll_and_stays_up() {
let scratch = Scratch::new();
let (config, credentials) = ready_document(&scratch);
let (mut served, lines) = serve(&config, &credentials);
let deadline = Instant::now() + Duration::from_secs(60);
let mut seen = Vec::new();
let poll_line = loop {
let left = deadline.saturating_duration_since(Instant::now());
match lines.recv_timeout(left) {
Ok(line) if line.contains("usage poll:") => break line,
Ok(line) => seen.push(line),
Err(_) => panic!("no usage poll state line; the connector said {seen:?}"),
}
};
assert!(
served.0.try_wait().unwrap().is_none(),
"the connector exited after starting the poll"
);
assert!(!poll_line.contains("CANARY"), "{poll_line}");
}