be more clear what negative cache means

This commit is contained in:
Emil Lerch 2026-05-18 16:18:26 -07:00
parent 51aaee9966
commit 2a89125977
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -62,6 +62,25 @@ pub const DataError = error{
AuthError,
};
/// Decide whether a provider failure is permanent enough to merit a
/// negative-cache entry. Negative entries suppress retries until the
/// next manual `--refresh` / `cache clear`, so writing one is only
/// safe when we're confident more attempts won't succeed.
///
/// Today the only certain-permanent failure is `NotFound`: the symbol
/// just doesn't have data of this type at this provider. Everything
/// else (rate limit, network blip, server 5xx, auth, parse error) is
/// either transient or fixable; recording a negative entry would
/// silently suppress retries for hours/days.
///
/// Rate-limit (`error.RateLimited`) is excluded here because callers
/// handle it specially (single retry after backoff). Anything that
/// reaches this classifier and isn't `NotFound` returns false
/// caller returns `FetchFailed` without poisoning the cache.
pub fn isPermanentProviderFailure(err: anyerror) bool {
return err == error.NotFound;
}
/// Re-exported provider types needed by commands via DataService.
pub const CompanyOverview = alphavantage.CompanyOverview;
@ -314,7 +333,14 @@ pub const DataService = struct {
s.write(T, symbol, retried, data_type.ttl());
return .{ .data = retried, .source = .fetched, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
}
s.writeNegative(symbol, data_type);
// Only NotFound (provider says "this symbol genuinely has
// no data of this type") gets a negative-cache entry.
// Transient failures (network, 5xx, auth misconfig, parse
// error) propagate as FetchFailed without poisoning the
// cache, so the next call retries naturally.
if (isPermanentProviderFailure(err)) {
s.writeNegative(symbol, data_type);
}
return DataError.FetchFailed;
};
@ -561,6 +587,11 @@ pub const DataService = struct {
}
return DataError.TransientError;
}
// FetchFailed at this point means BOTH Tiingo and Yahoo
// returned NotFound (or Yahoo was unavailable on top of
// Tiingo NotFound) symbol genuinely has no candle data
// anywhere we look. Negative-cache the result so we don't
// keep retrying nonexistent symbols.
s.writeNegative(symbol, .candles_daily);
return DataError.FetchFailed;
};
@ -634,7 +665,9 @@ pub const DataService = struct {
return DataError.FetchFailed;
};
}
s.writeNegative(symbol, .earnings);
if (isPermanentProviderFailure(err)) {
s.writeNegative(symbol, .earnings);
}
return DataError.FetchFailed;
};
@ -659,7 +692,9 @@ pub const DataService = struct {
return DataError.FetchFailed;
};
}
s.writeNegative(symbol, .etf_profile);
if (isPermanentProviderFailure(err)) {
s.writeNegative(symbol, .etf_profile);
}
return DataError.FetchFailed;
};
@ -1608,6 +1643,42 @@ pub const DataService = struct {
// Tests
test "isPermanentProviderFailure: NotFound is permanent" {
try std.testing.expect(isPermanentProviderFailure(error.NotFound));
}
test "isPermanentProviderFailure: RequestFailed is transient" {
try std.testing.expect(!isPermanentProviderFailure(error.RequestFailed));
}
test "isPermanentProviderFailure: ServerError is transient" {
try std.testing.expect(!isPermanentProviderFailure(error.ServerError));
}
test "isPermanentProviderFailure: Unauthorized is transient" {
// Auth misconfigs are user-fixable (set the API key); not a reason
// to permanently suppress retries.
try std.testing.expect(!isPermanentProviderFailure(error.Unauthorized));
}
test "isPermanentProviderFailure: InvalidResponse is transient" {
// Parse errors are usually a provider format change or one-off
// garbage response retrying later is fine.
try std.testing.expect(!isPermanentProviderFailure(error.InvalidResponse));
}
test "isPermanentProviderFailure: PaymentRequired is transient" {
// FMP marks plan-locked symbols with HTTP 402; user can upgrade
// their plan or rotate providers, so don't poison the cache.
try std.testing.expect(!isPermanentProviderFailure(error.PaymentRequired));
}
test "isPermanentProviderFailure: RateLimited is transient" {
// Rate-limit is the textbook transient case; the caller already
// handles it specially with backoff + retry.
try std.testing.expect(!isPermanentProviderFailure(error.RateLimited));
}
test "isMutualFund identifies mutual funds" {
// Standard mutual fund tickers (5 letters ending in X)
try std.testing.expect(DataService.isMutualFund("FDSCX"));