diff --git a/CHANGELOG.md b/CHANGELOG.md
index e65e3c9..6d9526b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,25 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+### Fixed
+
+- The usage endpoint is asked at most once every five minutes, whatever the refresh interval is
+ set to. A weekly window does not move in ninety seconds, and the tray is not the only client
+ spending the account's request quota — so polling that bought nothing was earning the 429 that
+ then froze the display for a quarter of an hour at a time. `claude.minIntervalSeconds` sets the
+ floor and the settings dialog exposes it; the menu's refresh command ignores it, because
+ someone asking for numbers now is entitled to the request. Codex is unaffected: it is a local
+ process with nothing to spend.
+- The account-wide Codex bucket is recognised by the id the payload states rather than by the
+ literal "codex". It has answered `premium` on this account before, and under that name it
+ would have been listed twice — once from `rateLimits`, once from `rateLimitsByLimitId` — as
+ two rows for one limit.
+- A Codex plan with a per-model quota says which model each limit belongs to. Two weekly windows
+ both read "Codex · Week", so the model's own limit — sitting at 0 % until that model runs —
+ looked like the account's limit had stopped filling. The model's window is now labelled after
+ it, the way Claude's per-model window already was, and only the account-wide window keeps the
+ "active" dot: whether a model is the one currently running is not something the server says.
+
## [0.3.1] - 2026-08-18
A patch release for three things that were wrong in plain sight: a limit that was full without
diff --git a/README.de.md b/README.de.md
index f009934..a9fbd3f 100644
--- a/README.de.md
+++ b/README.de.md
@@ -116,7 +116,8 @@ Alles lässt sich auch direkt in `%APPDATA%\RateTray\settings.json` bearbeiten:
"unknown": null,
"shadeSpread": 0.15 // Abstufung zwischen Limits eines Dienstes; 0 = aus
},
- "claude": { "enabled": true, "autoRefreshToken": false, "timeoutSeconds": 20 },
+ "claude": { "enabled": true, "autoRefreshToken": false, "timeoutSeconds": 20,
+ "minIntervalSeconds": 300 }, // Mindestabstand zwischen Usage-Abfragen
"codex": { "enabled": true, "executablePath": null, "timeoutSeconds": 30 }
}
```
diff --git a/README.md b/README.md
index 25d6b4e..c7b2d7c 100644
--- a/README.md
+++ b/README.md
@@ -113,7 +113,8 @@ Everything is also editable directly in `%APPDATA%\RateTray\settings.json`:
"unknown": null,
"shadeSpread": 0.15 // how far limits of one service are shaded apart; 0 = off
},
- "claude": { "enabled": true, "autoRefreshToken": false, "timeoutSeconds": 20 },
+ "claude": { "enabled": true, "autoRefreshToken": false, "timeoutSeconds": 20,
+ "minIntervalSeconds": 300 }, // floor under how often the usage endpoint is asked
"codex": { "enabled": true, "executablePath": null, "timeoutSeconds": 30 }
}
```
diff --git a/src/RateTray/Configuration/AppConfig.cs b/src/RateTray/Configuration/AppConfig.cs
index 9321947..31a87e9 100644
--- a/src/RateTray/Configuration/AppConfig.cs
+++ b/src/RateTray/Configuration/AppConfig.cs
@@ -214,6 +214,14 @@ public sealed class ClaudeOptions
/// How long to wait for the usage endpoint before giving up on a poll.
public int TimeoutSeconds { get; set; } = 20;
+ ///
+ /// Shortest gap between two requests to the usage endpoint, whatever the refresh interval
+ /// is set to. The endpoint has a request quota and the numbers behind it move in hours, so
+ /// asking it every ninety seconds spends the allowance without ever learning anything new —
+ /// and the 429 that follows costs the display for a quarter of an hour. 0 removes the floor.
+ ///
+ public int MinIntervalSeconds { get; set; } = 300;
+
///
/// Off by default: while Claude Code runs it keeps the token fresh on disk and we
/// simply re-read it. Turning this on lets the tray refresh the OAuth token itself
@@ -233,6 +241,7 @@ internal ClaudeOptions Normalize()
TokenUrl = Sane.Text(TokenUrl, TokenUrlDefault);
ClientId = Sane.Text(ClientId, ClientIdDefault);
TimeoutSeconds = Math.Clamp(TimeoutSeconds, 5, 300);
+ MinIntervalSeconds = Math.Clamp(MinIntervalSeconds, 0, 3_600);
return this;
}
}
diff --git a/src/RateTray/Localization/de.json b/src/RateTray/Localization/de.json
index e4cc3ae..27f6717 100644
--- a/src/RateTray/Localization/de.json
+++ b/src/RateTray/Localization/de.json
@@ -32,6 +32,7 @@
"settings.icons.rediscover": "Limits beim nächsten Abruf neu ermitteln",
"settings.claude.enabled": "Claude-Limits abrufen",
"settings.claude.autoRefresh": "OAuth-Token selbst erneuern (ungetesteter Pfad)",
+ "settings.claude.minInterval": "Mindestabstand zwischen Abfragen (Sekunden)",
"settings.claude.timeout": "Usage-API-Timeout (Sekunden)",
"settings.claude.credentials": "Anmeldedatei (leer = Standard)",
"settings.codex.enabled": "Codex-Limits abrufen",
@@ -107,6 +108,7 @@
"label.claude.extraUsage": "Extra-Guthaben",
"note.claude.extraCurrency": "Limit in {0}",
"label.codex.window": "Codex · {0}",
+ "label.codex.windowNamed": "{0} · {1}",
"note.endpoint": "Endpunkt: {0}",
"error.claude.noCredentials": "Keine Claude-Anmeldung gefunden (.credentials.json fehlt)",
diff --git a/src/RateTray/Localization/en.json b/src/RateTray/Localization/en.json
index 9d9b1c8..725e504 100644
--- a/src/RateTray/Localization/en.json
+++ b/src/RateTray/Localization/en.json
@@ -32,6 +32,7 @@
"settings.icons.rediscover": "Re-discover limits on next poll",
"settings.claude.enabled": "Read Claude limits",
"settings.claude.autoRefresh": "Refresh the OAuth token myself (untested path)",
+ "settings.claude.minInterval": "Min. gap between usage requests (seconds)",
"settings.claude.timeout": "Usage API timeout (seconds)",
"settings.claude.credentials": "Credentials file (empty = default)",
"settings.codex.enabled": "Read Codex limits",
@@ -107,6 +108,7 @@
"label.claude.extraUsage": "Extra credit",
"note.claude.extraCurrency": "Limit in {0}",
"label.codex.window": "Codex · {0}",
+ "label.codex.windowNamed": "{0} · {1}",
"note.endpoint": "Endpoint: {0}",
"error.claude.noCredentials": "No Claude sign-in found (.credentials.json missing)",
diff --git a/src/RateTray/Providers/ClaudeUsageProvider.cs b/src/RateTray/Providers/ClaudeUsageProvider.cs
index 16180fd..f0a3c3b 100644
--- a/src/RateTray/Providers/ClaudeUsageProvider.cs
+++ b/src/RateTray/Providers/ClaudeUsageProvider.cs
@@ -29,6 +29,8 @@ public sealed class ClaudeUsageProvider(ClaudeOptions options) : IUsageProvider
public bool Enabled => options.Enabled;
+ public TimeSpan MinInterval => TimeSpan.FromSeconds(Math.Max(0, options.MinIntervalSeconds));
+
private string CredentialsPath => options.CredentialsPath is { Length: > 0 } p
? Environment.ExpandEnvironmentVariables(p)
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", ".credentials.json");
diff --git a/src/RateTray/Providers/CodexUsageProvider.cs b/src/RateTray/Providers/CodexUsageProvider.cs
index 3bd5452..fe1c0e0 100644
--- a/src/RateTray/Providers/CodexUsageProvider.cs
+++ b/src/RateTray/Providers/CodexUsageProvider.cs
@@ -148,9 +148,11 @@ internal static List Parse(JsonObject result, out string? planType
var readings = new List();
// `rateLimits` is the single-bucket view; `rateLimitsByLimitId` may carry more.
+ string? covered = null;
if (result["rateLimits"] is JsonObject primaryBucket)
{
planType = primaryBucket["planType"]?.GetValue();
+ covered = primaryBucket["limitId"]?.GetValue() ?? "codex";
AddWindows(readings, primaryBucket, "codex", planType);
}
@@ -158,8 +160,14 @@ internal static List Parse(JsonObject result, out string? planType
{
foreach (var (limitId, node) in buckets)
{
- // The default bucket is already covered by `rateLimits` above.
- if (node is not JsonObject bucket || limitId.Equals("codex", StringComparison.OrdinalIgnoreCase)) continue;
+ if (node is not JsonObject bucket) continue;
+
+ // Whatever the account-wide bucket is called on this plan — it answers as
+ // "codex" here and has answered "premium" before — `rateLimits` already
+ // covered it. Matching the literal instead of the id the payload states would
+ // list that one limit a second time, as a row identical to the first.
+ if (covered is { } id && limitId.Equals(id, StringComparison.OrdinalIgnoreCase)) continue;
+
AddWindows(readings, bucket, $"codex.{Slug(limitId)}", bucket["planType"]?.GetValue());
}
}
@@ -170,8 +178,17 @@ internal static List Parse(JsonObject result, out string? planType
.ToList();
}
+ ///
+ /// Turns one bucket's windows into readings. Every bucket but the account-wide one belongs
+ /// to a single model and names it in limitName; without that name a plan with a
+ /// per-model quota shows two rows both reading "Codex · Week", and the one counting a model
+ /// that has not been used yet looks like the same limit refusing to move.
+ ///
private static void AddWindows(List readings, JsonObject bucket, string prefix, string? planType)
{
+ var accountWide = prefix == "codex";
+ var limitName = bucket["limitName"]?.GetValue();
+
foreach (var slot in (string[])["primary", "secondary"])
{
if (bucket[slot] is not JsonObject window) continue;
@@ -180,17 +197,27 @@ private static void AddWindows(List readings, JsonObject bucket, s
var minutes = window["windowDurationMins"]?.GetValue();
var span = minutes is > 0 ? TimeSpan.FromMinutes(minutes.Value) : (TimeSpan?)null;
var resets = window["resetsAt"]?.GetValue();
+ var windowText = span is { } s ? LimitReading.FormatWindow(s) : slot;
readings.Add(new LimitReading
{
- Id = prefix == "codex" ? $"codex.{slot}" : $"{prefix}.{slot}",
- Label = Loc.T("label.codex.window", span is { } s ? LimitReading.FormatWindow(s) : slot),
+ Id = accountWide ? $"codex.{slot}" : $"{prefix}.{slot}",
+ // "Week · ", the shape Claude's per-model window already uses, and
+ // without the service prefix on purpose: "Codex · Week · GPT-5.3-Codex-Spark"
+ // is wider than the label column, so the ellipsis would eat the model name —
+ // the only part that tells the two weekly rows apart.
+ Label = limitName is { Length: > 0 } name
+ ? Loc.T("label.codex.windowNamed", windowText, name)
+ : Loc.T("label.codex.window", windowText),
Group = "Codex",
Percent = percentNode.GetValue(),
ResetsAt = resets is > 0 ? DateTimeOffset.FromUnixTimeSeconds(resets.Value) : null,
Window = span,
Note = planType,
- IsActive = slot == "primary",
+ // Only the account-wide window is the one being spent right now: a model's own
+ // limit moves solely while that model runs, and the server does not say whether
+ // it is. Marking both as active made the two indistinguishable rows worse.
+ IsActive = slot == "primary" && accountWide,
});
}
}
diff --git a/src/RateTray/Providers/IUsageProvider.cs b/src/RateTray/Providers/IUsageProvider.cs
index fa5e136..257d0a6 100644
--- a/src/RateTray/Providers/IUsageProvider.cs
+++ b/src/RateTray/Providers/IUsageProvider.cs
@@ -9,5 +9,13 @@ public interface IUsageProvider
bool Enabled { get; }
+ ///
+ /// Shortest gap this provider may be polled at, regardless of the configured refresh
+ /// interval. A local process can be asked as often as the timer ticks; an endpoint with a
+ /// request quota cannot, and the tray is not the only thing spending it. Zero means the
+ /// refresh interval alone decides.
+ ///
+ TimeSpan MinInterval => TimeSpan.Zero;
+
Task ReadAsync(CancellationToken ct);
}
diff --git a/src/RateTray/Providers/PollScheduler.cs b/src/RateTray/Providers/PollScheduler.cs
index 4fe5e0d..3129744 100644
--- a/src/RateTray/Providers/PollScheduler.cs
+++ b/src/RateTray/Providers/PollScheduler.cs
@@ -15,10 +15,28 @@ public sealed class PollScheduler(int maxBackoffMinutes = 15)
{
private readonly Dictionary _failures = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _retryAt = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _minInterval = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _polledAt = new(StringComparer.OrdinalIgnoreCase);
- /// False while the provider is backed off. Only a real poll may follow a true.
- public bool ShouldPoll(string group, DateTimeOffset now) =>
- !_retryAt.TryGetValue(group, out var until) || now >= until;
+ ///
+ /// Sets a floor under how often this provider is asked, independent of the refresh
+ /// interval and of whether it is failing. Configuration rather than state: it survives
+ /// , which only releases what a failure or a poll put there.
+ ///
+ public void SetMinInterval(string group, TimeSpan interval) =>
+ _minInterval[group] = interval > TimeSpan.Zero ? interval : TimeSpan.Zero;
+
+ ///
+ /// False while the provider is backed off, and false again until its minimum gap has
+ /// passed. Only a real poll may follow a true.
+ ///
+ public bool ShouldPoll(string group, DateTimeOffset now)
+ {
+ if (_retryAt.TryGetValue(group, out var until) && now < until) return false;
+
+ return !_polledAt.TryGetValue(group, out var last)
+ || now >= last + _minInterval.GetValueOrDefault(group);
+ }
/// When the provider will be tried again, or null if it is not backed off.
public DateTimeOffset? RetryAt(string group) =>
@@ -26,10 +44,15 @@ public bool ShouldPoll(string group, DateTimeOffset now) =>
public int Failures(string group) => _failures.GetValueOrDefault(group);
- public void RecordSuccess(string group)
+ /// When this provider was last actually reached, or null if it never was.
+ public DateTimeOffset? PolledAt(string group) =>
+ _polledAt.TryGetValue(group, out var last) ? last : null;
+
+ public void RecordSuccess(string group, DateTimeOffset now)
{
_failures.Remove(group);
_retryAt.Remove(group);
+ _polledAt[group] = now;
}
///
@@ -42,17 +65,23 @@ public DateTimeOffset RecordFailure(string group, TimeSpan? statedDelay, DateTim
{
var failures = _failures.GetValueOrDefault(group) + 1;
_failures[group] = failures;
+ _polledAt[group] = now;
var until = now + Backoff(statedDelay, failures, refreshSeconds, maxBackoffMinutes, rateLimited);
_retryAt[group] = until;
return until;
}
- /// Clears every pause, so an explicit refresh reaches all providers at once.
+ ///
+ /// Clears every pause — the backoff and the minimum gap alike — so an explicit refresh
+ /// reaches all providers at once. Someone who asks for numbers now is entitled to the
+ /// request, even where the tray would have spaced it out on its own.
+ ///
public void Reset()
{
_failures.Clear();
_retryAt.Clear();
+ _polledAt.Clear();
}
///
diff --git a/src/RateTray/TrayApp.cs b/src/RateTray/TrayApp.cs
index 63d63a4..ed78644 100644
--- a/src/RateTray/TrayApp.cs
+++ b/src/RateTray/TrayApp.cs
@@ -86,6 +86,7 @@ public TrayApp()
_lastGood = UsageCache.Load();
_schedule = new PollScheduler(_config.MaxBackoffMinutes);
+ ApplyPollSpacing();
BuildMenu();
_menu.Opened += (_, _) => HideTooltip();
@@ -199,15 +200,16 @@ private void ShowCachedReadings()
///
private ProviderResult RememberAndRestore(ProviderResult result)
{
+ var now = DateTimeOffset.Now;
+
if (result.Ok)
{
- _lastGood[result.Group] = new CachedReadings(DateTimeOffset.Now, result.Readings);
- _schedule.RecordSuccess(result.Group);
+ _lastGood[result.Group] = new CachedReadings(now, result.Readings);
+ _schedule.RecordSuccess(result.Group, now);
UsageCache.Save(_lastGood);
return result;
}
- var now = DateTimeOffset.Now;
var retryAt = _schedule.RecordFailure(
result.Group, result.RetryAfter, now, _config.RefreshSeconds, result.RateLimited);
@@ -228,6 +230,17 @@ private ProviderResult RememberAndRestore(ProviderResult result)
private void ScheduleNextPoll() => _nextPoll = DateTimeOffset.Now.AddMilliseconds(_timer.Interval);
+ ///
+ /// Hands each provider's own floor to the scheduler, so the timer may tick as often as the
+ /// fastest of them allows. Asked of the providers rather than decided here: whether a poll
+ /// costs anything is a property of what is being polled — a local process that answers for
+ /// free, or a metered endpoint the tray is not the only client of.
+ ///
+ private void ApplyPollSpacing()
+ {
+ foreach (var provider in _providers) _schedule.SetMinInterval(provider.Group, provider.MinInterval);
+ }
+
private ProviderResult LastResultFor(string group) =>
_lastResults.FirstOrDefault(r => r.Group == group)
?? ProviderResult.Failed(group, Loc.T("error.backoff"));
@@ -614,6 +627,7 @@ private void OpenSettings()
Loc.Use(_config.Language);
_timer.Interval = PollIntervalMs;
+ ApplyPollSpacing();
ScheduleNextPoll();
// Both windows cache fonts and colours at construction, so they are rebuilt rather
diff --git a/src/RateTray/Ui/SettingsForm.cs b/src/RateTray/Ui/SettingsForm.cs
index 34dadaa..fa0f434 100644
--- a/src/RateTray/Ui/SettingsForm.cs
+++ b/src/RateTray/Ui/SettingsForm.cs
@@ -47,6 +47,7 @@ public sealed class SettingsForm : Form
private readonly CheckBox _codexEnabled = new();
private readonly TextBox _codexPath = new();
private readonly NumericUpDown _claudeTimeout = new() { Minimum = 5, Maximum = 300 };
+ private readonly NumericUpDown _claudeMinInterval = new() { Minimum = 0, Maximum = 3600, Increment = 30 };
private readonly NumericUpDown _codexTimeout = new() { Minimum = 5, Maximum = 300 };
private Color _claudeValue;
@@ -162,6 +163,7 @@ private TabPage ServicesPage()
AddFullRow(grid, Check(_claudeAutoRefresh, Loc.T("settings.claude.autoRefresh")));
AddRow(grid, Loc.T("settings.claude.credentials"), WithBrowse(_claudeCredentials));
AddRow(grid, Loc.T("settings.claude.timeout"), _claudeTimeout);
+ AddRow(grid, Loc.T("settings.claude.minInterval"), _claudeMinInterval);
AddFullRow(grid, new Label { Height = 8, Dock = DockStyle.Fill });
AddFullRow(grid, Check(_codexEnabled, Loc.T("settings.codex.enabled")));
AddRow(grid, Loc.T("settings.codex.path"), WithBrowse(_codexPath));
@@ -350,6 +352,7 @@ private void ReadConfig()
_claudeAutoRefresh.Checked = _config.Claude.AutoRefreshToken;
_claudeCredentials.Text = _config.Claude.CredentialsPath ?? "";
_claudeTimeout.Value = Math.Clamp(_config.Claude.TimeoutSeconds, 5, 300);
+ _claudeMinInterval.Value = Math.Clamp(_config.Claude.MinIntervalSeconds, 0, 3600);
_codexEnabled.Checked = _config.Codex.Enabled;
_codexPath.Text = _config.Codex.ExecutablePath ?? "";
_codexTimeout.Value = Math.Clamp(_config.Codex.TimeoutSeconds, 5, 300);
@@ -384,6 +387,7 @@ private void WriteConfig()
_config.Claude.AutoRefreshToken = _claudeAutoRefresh.Checked;
_config.Claude.CredentialsPath = Empty(_claudeCredentials.Text);
_config.Claude.TimeoutSeconds = (int)_claudeTimeout.Value;
+ _config.Claude.MinIntervalSeconds = (int)_claudeMinInterval.Value;
_config.Codex.Enabled = _codexEnabled.Checked;
_config.Codex.ExecutablePath = Empty(_codexPath.Text);
_config.Codex.TimeoutSeconds = (int)_codexTimeout.Value;
diff --git a/tests/RateTray.Tests/CodexUsageProviderTests.cs b/tests/RateTray.Tests/CodexUsageProviderTests.cs
index b35998d..fcd83cb 100644
--- a/tests/RateTray.Tests/CodexUsageProviderTests.cs
+++ b/tests/RateTray.Tests/CodexUsageProviderTests.cs
@@ -11,13 +11,15 @@ public class CodexUsageProviderTests
{
"rateLimits": {
"limitId": "codex",
+ "limitName": null,
"primary": { "usedPercent": 6, "windowDurationMins": 10080, "resetsAt": 1786468203 },
"secondary": { "usedPercent": 41, "windowDurationMins": 300, "resetsAt": 1786400000 },
"planType": "plus"
},
"rateLimitsByLimitId": {
- "codex": { "limitId": "codex", "primary": { "usedPercent": 6, "windowDurationMins": 10080, "resetsAt": 1786468203 }, "planType": "plus" },
- "codex-max": { "limitId": "codex-max", "primary": { "usedPercent": 3, "windowDurationMins": 1440, "resetsAt": 1786468203 }, "planType": "pro" }
+ "codex": { "limitId": "codex", "limitName": null, "primary": { "usedPercent": 6, "windowDurationMins": 10080, "resetsAt": 1786468203 }, "planType": "plus" },
+ "codex-max": { "limitId": "codex-max", "primary": { "usedPercent": 3, "windowDurationMins": 1440, "resetsAt": 1786468203 }, "planType": "pro" },
+ "codex_spark": { "limitId": "codex_spark", "limitName": "GPT-5.3-Codex-Spark", "primary": { "usedPercent": 0, "windowDurationMins": 10080, "resetsAt": 1786500000 }, "planType": "plus" }
}
}
""";
@@ -63,6 +65,63 @@ public void Parse_adds_extra_buckets_without_duplicating_the_default_one()
Assert.Contains(readings, r => r.Id == "codex.codex_max.primary" && r.Percent == 3);
}
+ ///
+ /// A plan with a per-model quota reports two weekly windows: the account's and the model's.
+ /// Both read "Codex · Week" until the model is named, and the model's — untouched at 0 %
+ /// until that model runs — then looks like a bar that stopped filling.
+ ///
+ [Fact]
+ public void Parse_names_a_per_model_bucket_after_the_model()
+ {
+ var readings = CodexUsageProvider.Parse(Json(Payload), out _);
+
+ var account = readings.Single(r => r.Id == "codex.primary");
+ var model = readings.Single(r => r.Id == "codex.codex_spark.primary");
+
+ Assert.Equal("Codex · Week", account.Label);
+ Assert.Equal("Week · GPT-5.3-Codex-Spark", model.Label);
+ }
+
+ [Fact]
+ public void Parse_falls_back_to_the_window_where_a_bucket_is_unnamed()
+ {
+ var readings = CodexUsageProvider.Parse(Json(Payload), out _);
+
+ Assert.Equal("Codex · 1 d", readings.Single(r => r.Id == "codex.codex_max.primary").Label);
+ }
+
+ [Fact]
+ public void Parse_marks_only_the_account_wide_window_as_active()
+ {
+ // A model's own limit moves solely while that model runs, and the server does not say
+ // whether it is. A dot on both windows claims something we do not know and costs the
+ // one that is true its meaning.
+ var readings = CodexUsageProvider.Parse(Json(Payload), out _);
+
+ Assert.True(readings.Single(r => r.Id == "codex.primary").IsActive);
+ Assert.False(readings.Single(r => r.Id == "codex.codex_spark.primary").IsActive);
+ }
+
+ [Fact]
+ public void Parse_recognises_the_default_bucket_by_the_id_the_payload_states()
+ {
+ // The account-wide bucket is not always called "codex": this account has reported
+ // "premium" too. Skipping the literal rather than the stated id would list that one
+ // limit twice — once from rateLimits, once from the map — as two identical rows.
+ const string payload = """
+ {
+ "rateLimits": { "limitId": "premium", "primary": { "usedPercent": 6, "windowDurationMins": 10080 }, "planType": "plus" },
+ "rateLimitsByLimitId": {
+ "premium": { "limitId": "premium", "primary": { "usedPercent": 6, "windowDurationMins": 10080 }, "planType": "plus" }
+ }
+ }
+ """;
+
+ var reading = Assert.Single(CodexUsageProvider.Parse(Json(payload), out _));
+
+ Assert.Equal("codex.primary", reading.Id);
+ }
+
[Fact]
public void Parse_skips_a_window_without_a_used_percentage()
{
diff --git a/tests/RateTray.Tests/PollSchedulerTests.cs b/tests/RateTray.Tests/PollSchedulerTests.cs
index cb6a38b..459e94b 100644
--- a/tests/RateTray.Tests/PollSchedulerTests.cs
+++ b/tests/RateTray.Tests/PollSchedulerTests.cs
@@ -73,7 +73,7 @@ public void Success_clears_the_pause_and_the_failure_count()
var schedule = new PollScheduler();
schedule.RecordFailure("Claude", null, Start, 90);
- schedule.RecordSuccess("Claude");
+ schedule.RecordSuccess("Claude", Start);
Assert.True(schedule.ShouldPoll("Claude", Start));
Assert.Null(schedule.RetryAt("Claude"));
@@ -116,6 +116,109 @@ public void After_a_reset_the_backoff_starts_over_rather_than_where_it_left_off(
Assert.Equal(Start.AddSeconds(180), retryAt);
}
+ // ---------------------------------------------------------- minimum spacing
+
+ ///
+ /// The reason this exists: the usage endpoint has a request quota, the tray was asking it
+ /// every ninety seconds for numbers that move in hours, and the 429 that earned cost the
+ /// display a quarter of an hour at a time.
+ ///
+ [Fact]
+ public void A_provider_with_a_minimum_gap_is_skipped_until_it_has_passed()
+ {
+ var schedule = new PollScheduler();
+ schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(5));
+
+ schedule.RecordSuccess("Claude", Start);
+
+ Assert.False(schedule.ShouldPoll("Claude", Start.AddSeconds(90)));
+ Assert.False(schedule.ShouldPoll("Claude", Start.AddSeconds(299)));
+ Assert.True(schedule.ShouldPoll("Claude", Start.AddSeconds(300)));
+ }
+
+ [Fact]
+ public void The_gap_is_measured_from_a_failed_poll_too()
+ {
+ // Whether the answer was usable does not change what the request cost.
+ var schedule = new PollScheduler();
+ schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(30));
+
+ schedule.RecordFailure("Claude", null, Start, refreshSeconds: 90);
+
+ Assert.False(schedule.ShouldPoll("Claude", Start.AddMinutes(29)));
+ Assert.True(schedule.ShouldPoll("Claude", Start.AddMinutes(30)));
+ }
+
+ [Fact]
+ public void Skipping_for_the_gap_does_not_count_as_a_failure()
+ {
+ var schedule = new PollScheduler();
+ schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(5));
+ schedule.RecordSuccess("Claude", Start);
+
+ for (var tick = 1; tick <= 3; tick++) schedule.ShouldPoll("Claude", Start.AddSeconds(90d * tick));
+
+ Assert.Equal(0, schedule.Failures("Claude"));
+ Assert.Null(schedule.RetryAt("Claude"));
+ }
+
+ [Fact]
+ public void A_provider_without_a_gap_is_polled_every_cycle()
+ {
+ var schedule = new PollScheduler();
+ schedule.RecordSuccess("Codex", Start);
+
+ Assert.True(schedule.ShouldPoll("Codex", Start));
+ }
+
+ [Fact]
+ public void The_first_poll_is_never_held_back()
+ {
+ var schedule = new PollScheduler();
+ schedule.SetMinInterval("Claude", TimeSpan.FromHours(1));
+
+ Assert.True(schedule.ShouldPoll("Claude", Start));
+ Assert.Null(schedule.PolledAt("Claude"));
+ }
+
+ [Fact]
+ public void An_explicit_refresh_beats_the_gap()
+ {
+ // Someone asking for numbers now is entitled to the request; the floor is there to
+ // stop the timer spending the quota, not to overrule the person watching.
+ var schedule = new PollScheduler();
+ schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(5));
+ schedule.RecordSuccess("Claude", Start);
+
+ schedule.Reset();
+
+ Assert.True(schedule.ShouldPoll("Claude", Start));
+ }
+
+ [Fact]
+ public void The_gap_survives_a_reset_because_it_is_configuration()
+ {
+ var schedule = new PollScheduler();
+ schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(5));
+
+ schedule.Reset();
+ schedule.RecordSuccess("Claude", Start);
+
+ Assert.False(schedule.ShouldPoll("Claude", Start.AddMinutes(4)));
+ }
+
+ [Fact]
+ public void A_backoff_longer_than_the_gap_still_wins()
+ {
+ var schedule = new PollScheduler();
+ schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(1));
+
+ schedule.RecordFailure("Claude", null, Start, refreshSeconds: 90, rateLimited: true);
+
+ Assert.False(schedule.ShouldPoll("Claude", Start.AddMinutes(14)));
+ Assert.True(schedule.ShouldPoll("Claude", Start.AddMinutes(15)));
+ }
+
// ----------------------------------------------------------------- backoff
[Fact]