feat(usage): hold the last good TokenTracker export fail-open

This commit is contained in:
mika kuns
2026-08-24 13:36:59 +02:00
parent 2fca0cc4d3
commit 580d4e0717
2 changed files with 121 additions and 0 deletions
@@ -0,0 +1,55 @@
namespace ClaudeDo.Worker.Usage.TokenTracker;
/// <summary>
/// Threadsafe holder for the last successful export. Mirrors <see cref="UsageState"/>: a failed
/// fetch never overwrites a good export, it only records <see cref="LastError"/>.
/// </summary>
public sealed class TokenTrackerState
{
private readonly object _lock = new();
private TokenTrackerExport? _export;
private DateTime? _lastAttemptUtc;
private string? _lastError;
public TokenTrackerExport? Export
{
get { lock (_lock) return _export; }
}
public DateTime? LastAttemptUtc
{
get { lock (_lock) return _lastAttemptUtc; }
}
public string? LastError
{
get { lock (_lock) return _lastError; }
}
public void ReportSuccess(TokenTrackerExport export)
{
lock (_lock)
{
_export = export;
_lastAttemptUtc = export.FetchedAtUtc;
_lastError = null;
}
}
public void ReportFailure(string error, DateTime attemptedAtUtc)
{
lock (_lock)
{
_lastAttemptUtc = attemptedAtUtc;
_lastError = error;
}
}
public bool IsOlderThan(TimeSpan maxAge, DateTime nowUtc)
{
lock (_lock)
{
return _export is null || nowUtc - _export.FetchedAtUtc > maxAge;
}
}
}
@@ -0,0 +1,66 @@
using ClaudeDo.Worker.Usage.TokenTracker;
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
public sealed class TokenTrackerStateTests
{
private static TokenTrackerExport Export(DateTime fetchedAt) =>
new(11, [], fetchedAt);
[Fact]
public void FreshState_IsEmpty()
{
var state = new TokenTrackerState();
Assert.Null(state.Export);
Assert.Null(state.LastAttemptUtc);
Assert.Null(state.LastError);
}
[Fact]
public void ReportSuccess_StoresExportAndClearsError()
{
var state = new TokenTrackerState();
var at = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc);
state.ReportFailure("boom", at.AddMinutes(-1));
state.ReportSuccess(Export(at));
Assert.NotNull(state.Export);
Assert.Equal(at, state.LastAttemptUtc);
Assert.Null(state.LastError);
}
[Fact]
public void ReportFailure_KeepsPreviousExport()
{
var state = new TokenTrackerState();
var good = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc);
state.ReportSuccess(Export(good));
state.ReportFailure("tokentracker exited with code 1", good.AddMinutes(30));
Assert.NotNull(state.Export);
Assert.Equal(good, state.Export!.FetchedAtUtc);
Assert.Equal("tokentracker exited with code 1", state.LastError);
Assert.Equal(good.AddMinutes(30), state.LastAttemptUtc);
}
[Fact]
public void IsOlderThan_NoExport_IsTrue()
{
Assert.True(new TokenTrackerState().IsOlderThan(TimeSpan.FromMinutes(15),
new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc)));
}
[Fact]
public void IsOlderThan_ComparesAgainstFetchTime()
{
var state = new TokenTrackerState();
var fetched = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc);
state.ReportSuccess(Export(fetched));
Assert.False(state.IsOlderThan(TimeSpan.FromMinutes(15), fetched.AddMinutes(14)));
Assert.True(state.IsOlderThan(TimeSpan.FromMinutes(15), fetched.AddMinutes(16)));
}
}