From 481428a7cefb09aa89c60089c689930dc4ebc2c2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 03:17:08 +0100 Subject: [PATCH] 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 --- crates/claude-connector/src/usage_poll.rs | 108 +++++++++++- crates/claude-connector/tests/serve_config.rs | 163 ------------------ 2 files changed, 103 insertions(+), 168 deletions(-) delete mode 100644 crates/claude-connector/tests/serve_config.rs diff --git a/crates/claude-connector/src/usage_poll.rs b/crates/claude-connector/src/usage_poll.rs index e23a74f..ff61482 100644 --- a/crates/claude-connector/src/usage_poll.rs +++ b/crates/claude-connector/src/usage_poll.rs @@ -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 { let value = value.trim(); if let Ok(seconds) = value.parse::() { 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 { + 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::().ok()?; + let year = if year > now_year + 50 { + year - 100 + } else { + year + }; + (day, month, year, *time) + } + [_, month, day, time, year] => (*day, *month, year.parse::().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:?}"); } } diff --git a/crates/claude-connector/tests/serve_config.rs b/crates/claude-connector/tests/serve_config.rs deleted file mode 100644 index 50a6279..0000000 --- a/crates/claude-connector/tests/serve_config.rs +++ /dev/null @@ -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) { - 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}"); -}