add response header when stale data is served

This commit is contained in:
Emil Lerch 2026-11-04 09:09:55 -08:00
parent c65a034bf0
commit 0fc03e9a20
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 83 additions and 2 deletions

View file

@ -159,6 +159,11 @@ fn handleWeatherInternal(
}; };
defer weather.deinit(); defer weather.deinit();
// The upstream is down and this is old data. Say so, with how long past its
// expiry it is, so scripts and status bars can decide whether to trust it.
if (weather.stale_for_s) |stale_for_s|
res.headers.add("X-Weather-Stale", try std.fmt.allocPrint(res.arena, "{d}", .{stale_for_s}));
// Set display name for rendering // Set display name for rendering
weather.display_name = try req_alloc.dupe(u8, location.name); weather.display_name = try req_alloc.dupe(u8, location.name);
@ -771,3 +776,68 @@ test "handler: upstream weather failure with nothing cached is a retryable 503"
try ht.expectStatus(503); try ht.expectStatus(503);
try ht.expectHeader("Retry-After", "60"); try ht.expectHeader("Retry-After", "60");
} }
test "handler: stale data is served with an X-Weather-Stale header" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
const Pins = @import("../location/Pins.zig");
const Location = @import("../location/resolver.zig").Location;
const Cache = @import("../cache/Cache.zig");
var harness = try MockHarness.init(allocator);
defer harness.deinit();
harness.mock.fail_with = error.WeatherApiFailed;
// Pinned so the cache key does not depend on the GeoLite2 database
try harness.geoip.pins.put(
try Pins.parseCidr("203.0.113.0/24"),
"Seattle, Washington, United States",
.{ .latitude = 47.6, .longitude = -122.3 },
Location.isoFrom("US"),
);
// Own cache, for the same reason as the 503 test above, holding an entry
// for the pinned coordinates that expired five minutes ago
const cache = try Cache.init(allocator, std.testing.io, .{
.cache_dir = null,
.stale_retention_s = WeatherProvider.stale_retention_s,
});
defer cache.deinit();
const now = std.Io.Timestamp.now(std.testing.io, .real).toMilliseconds();
try cache.lru.put("47.6000,-122.3000", "{}", now - 5 * 60 * 1000);
harness.opts.provider = harness.mock.provider(cache);
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/203.0.113.7?format=1");
ht.param("location", "203.0.113.7");
try handleWeather(&harness.opts, ht.req, ht.res, "203.0.113.7");
try ht.expectStatus(200);
try ht.expectBody("☀️ +68°F\n");
const pr = try ht.parseResponse();
const stale_for_s = try std.fmt.parseInt(i64, pr.headers.get("X-Weather-Stale").?, 10);
try std.testing.expect(stale_for_s >= 300 and stale_for_s < 360);
}
test "handler: fresh data has no X-Weather-Stale header" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/73.158.64.1?format=1");
ht.param("location", "73.158.64.1");
try handleWeather(&harness.opts, ht.req, ht.res, "73.158.64.1");
try ht.expectStatus(200);
const pr = try ht.parseResponse();
try std.testing.expect(pr.headers.get("X-Weather-Stale") == null);
}

View file

@ -43,12 +43,15 @@ pub fn fetch(self: WeatherProvider, allocator: std.mem.Allocator, coords: Coordi
const raw = self.vtable.fetchRaw(self.ptr, allocator, coords) catch |err| { const raw = self.vtable.fetchRaw(self.ptr, allocator, coords) catch |err| {
if (self.cache.getStale(cache_key)) |stale| { if (self.cache.getStale(cache_key)) |stale| {
const now_ms = std.Io.Timestamp.now(self.cache.io, .real).toMilliseconds(); const now_ms = std.Io.Timestamp.now(self.cache.io, .real).toMilliseconds();
const stale_for_s = @divTrunc(now_ms - stale.expires, std.time.ms_per_s);
log.warn("weather fetch for {s} failed ({}); serving cached data that expired {d}s ago", .{ log.warn("weather fetch for {s} failed ({}); serving cached data that expired {d}s ago", .{
cache_key, cache_key,
err, err,
@divTrunc(now_ms - stale.expires, std.time.ms_per_s), stale_for_s,
}); });
return self.vtable.parse(self.ptr, allocator, stale.value); var weather = try self.vtable.parse(self.ptr, allocator, stale.value);
weather.stale_for_s = stale_for_s;
return weather;
} }
return err; return err;
}; };
@ -98,6 +101,8 @@ test "provider fetch" {
try std.testing.expect(weather1.forecast.len > 0); try std.testing.expect(weather1.forecast.len > 0);
try std.testing.expect(weather2.forecast.len > 0); try std.testing.expect(weather2.forecast.len > 0);
try std.testing.expect(weather1.stale_for_s == null);
try std.testing.expect(weather2.stale_for_s == null);
} }
test "provider serves expired cached data when the upstream fails" { test "provider serves expired cached data when the upstream fails" {
@ -129,6 +134,9 @@ test "provider serves expired cached data when the upstream fails" {
const weather = try provider.fetch(std.testing.allocator, coords); const weather = try provider.fetch(std.testing.allocator, coords);
defer weather.deinit(); defer weather.deinit();
try std.testing.expect(weather.forecast.len > 0); try std.testing.expect(weather.forecast.len > 0);
// Marked stale, by about the ten minutes it has been expired
const stale_for_s = weather.stale_for_s.?;
try std.testing.expect(stale_for_s >= 600 and stale_for_s < 660);
} }
test "provider does not serve data past the stale retention window" { test "provider does not serve data past the stale retention window" {

View file

@ -91,6 +91,9 @@ pub const WeatherData = struct {
current: CurrentCondition, current: CurrentCondition,
forecast: []ForecastDay, forecast: []ForecastDay,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
/// Set when the upstream was unavailable and this came from an expired
/// cache entry: seconds since that entry expired. Null for fresh data.
stale_for_s: ?i64 = null,
pub fn deinit(self: WeatherData) void { pub fn deinit(self: WeatherData) void {
self.allocator.free(self.location); self.allocator.free(self.location);