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:
parent
e17ef16d48
commit
481428a7ce
2 changed files with 103 additions and 168 deletions
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
Loading…
Reference in a new issue