feat(usage): hold the last good TokenTracker export fail-open
This commit is contained in:
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user