From 0fc03e9a202cd0be982bc57df0d4fe1242448391 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 4 Nov 2026 09:09:55 -0800 Subject: [PATCH] add response header when stale data is served --- src/http/handler.zig | 70 ++++++++++++++++++++++++++++++++++++++++ src/weather/Provider.zig | 12 +++++-- src/weather/types.zig | 3 ++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/http/handler.zig b/src/http/handler.zig index 7c9032b..cff5eff 100644 --- a/src/http/handler.zig +++ b/src/http/handler.zig @@ -159,6 +159,11 @@ fn handleWeatherInternal( }; 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 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.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); +} diff --git a/src/weather/Provider.zig b/src/weather/Provider.zig index c8f309b..9394f2b 100644 --- a/src/weather/Provider.zig +++ b/src/weather/Provider.zig @@ -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| { if (self.cache.getStale(cache_key)) |stale| { 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", .{ cache_key, 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; }; @@ -98,6 +101,8 @@ test "provider fetch" { try std.testing.expect(weather1.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" { @@ -129,6 +134,9 @@ test "provider serves expired cached data when the upstream fails" { const weather = try provider.fetch(std.testing.allocator, coords); defer weather.deinit(); 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" { diff --git a/src/weather/types.zig b/src/weather/types.zig index f8272a4..b6ab1ff 100644 --- a/src/weather/types.zig +++ b/src/weather/types.zig @@ -91,6 +91,9 @@ pub const WeatherData = struct { current: CurrentCondition, forecast: []ForecastDay, 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 { self.allocator.free(self.location);