From 7e56e839532a9df03247db1301a2f4ceb58afcc2 Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 1 Oct 2026 17:12:00 +0200 Subject: [PATCH] Serve static maintenance page with 503 when PocketBase is unreachable --- .../PocketBaseHealthCacheTests.cs | 73 ++++++ Elternbeirat.Web.Tests/RouteSmokeTests.cs | 56 +++++ Elternbeirat.Web/Features/Home/Home.razor.cs | 12 +- Elternbeirat.Web/Program.cs | 37 +++ .../Shared/PocketBaseHealthCache.cs | 73 ++++++ Elternbeirat.Web/wwwroot/maintenance.html | 228 ++++++++++++++++++ docs/deployment.md | 25 ++ 7 files changed, 500 insertions(+), 4 deletions(-) create mode 100644 Elternbeirat.Web.Tests/PocketBaseHealthCacheTests.cs create mode 100644 Elternbeirat.Web/Shared/PocketBaseHealthCache.cs create mode 100644 Elternbeirat.Web/wwwroot/maintenance.html diff --git a/Elternbeirat.Web.Tests/PocketBaseHealthCacheTests.cs b/Elternbeirat.Web.Tests/PocketBaseHealthCacheTests.cs new file mode 100644 index 0000000..897a8f2 --- /dev/null +++ b/Elternbeirat.Web.Tests/PocketBaseHealthCacheTests.cs @@ -0,0 +1,73 @@ +using Elternbeirat.Web.Shared; + +namespace Elternbeirat.Web.Tests; + +/// +/// Unit tests for the health cache behind the maintenance gate. The probe is a fake +/// that counts its calls, so no PocketBase is needed. +/// +public class PocketBaseHealthCacheTests +{ + private readonly SteppingClock _clock = new(new DateTimeOffset(2026, 10, 1, 10, 0, 0, TimeSpan.Zero)); + private int _probes; + + private Func> Probe(bool healthy) => + _ => + { + _probes++; + return Task.FromResult(healthy); + }; + + [Fact] + public async Task Result_is_reused_within_the_lifetime() + { + var cache = new PocketBaseHealthCache(_clock); + + await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None); + _clock.Now += PocketBaseHealthCache.Lifetime - TimeSpan.FromSeconds(1); + var healthy = await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None); + + healthy.ShouldBeTrue(); + _probes.ShouldBe(1); + } + + [Fact] + public async Task Stale_result_is_probed_again() + { + // The way back after an outage: once the lifetime is over, the new answer + // replaces the old one. + var cache = new PocketBaseHealthCache(_clock); + + await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None); + _clock.Now += PocketBaseHealthCache.Lifetime; + var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None); + + healthy.ShouldBeTrue(); + _probes.ShouldBe(2); + } + + [Fact] + public async Task Unhealthy_result_is_cached_too() + { + // While PocketBase is down, not every request should wait for a failing probe. + var cache = new PocketBaseHealthCache(_clock); + + await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None); + var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None); + + healthy.ShouldBeFalse(); + _probes.ShouldBe(1); + } + + /// + /// A clock the test moves forward by hand, to step past the cache lifetime without + /// waiting. + /// + /// The moment the clock shows at first. + private sealed class SteppingClock(DateTimeOffset start) : TimeProvider + { + public DateTimeOffset Now { get; set; } = start; + + public override DateTimeOffset GetUtcNow() => Now; + } +} diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs index 6d62cfe..888f739 100644 --- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -308,8 +308,64 @@ public sealed partial class RouteSmokeTests : IDisposable var response = await client.GetAsync(new Uri("/health", UriKind.Relative)); response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable); + (await response.Content.ReadAsStringAsync()).ShouldBe("PocketBase unreachable"); } + [Theory] + [InlineData("/")] + [InlineData("/board")] + [InlineData("/posts/new-board")] + public async Task Page_shows_maintenance_page_when_PocketBase_is_unreachable(string route) + { + // The maintenance gate: with PocketBase down, a page is not rendered half-empty + // but answered with the static maintenance.html, a 503 and a hint when to retry. + await using var factory = UnreachablePocketBase(); + var client = factory.CreateClient(); + + var response = await client.GetAsync(new Uri(route, UriKind.Relative)); + + response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable); + response.Headers.RetryAfter.ShouldNotBeNull(); + (await response.Content.ReadAsStringAsync()).ShouldContain("Die Seite ist gerade nicht erreichbar"); + } + + [Fact] + public async Task Calendar_feed_stays_an_empty_calendar_when_PocketBase_is_unreachable() + { + // Not a page, so the gate lets it through: subscribed calendar apps keep + // getting a valid (empty) calendar instead of an HTML page. + await using var factory = UnreachablePocketBase(); + var client = factory.CreateClient(); + + var response = await client.GetAsync(new Uri("/events.ics", UriKind.Relative)); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).ShouldContain("BEGIN:VCALENDAR"); + } + + [Fact] + public async Task Maintenance_page_and_its_font_stay_reachable_when_PocketBase_is_unreachable() + { + // Static files are not gated: the maintenance page loads its self-hosted font + // from /fonts while PocketBase is down. + await using var factory = UnreachablePocketBase(); + var client = factory.CreateClient(); + + var page = await client.GetAsync(new Uri("/maintenance.html", UriKind.Relative)); + var font = await client.GetAsync(new Uri("/fonts/nunito-latin-wght.woff2", UriKind.Relative)); + + page.StatusCode.ShouldBe(HttpStatusCode.OK); + font.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + /// + /// A throwaway app pointed at a dead address: nothing listens on port 1, so every + /// PocketBase call is refused at once, without a timeout wait. + /// + private WebApplicationFactory UnreachablePocketBase() => + _baseFactory.WithWebHostBuilder(builder => + builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1")); + [Fact] public async Task Home_hero_takes_title_and_buttons_from_the_body() { diff --git a/Elternbeirat.Web/Features/Home/Home.razor.cs b/Elternbeirat.Web/Features/Home/Home.razor.cs index 38100c5..e030213 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor.cs +++ b/Elternbeirat.Web/Features/Home/Home.razor.cs @@ -35,8 +35,8 @@ public partial class Home private HttpContext? HttpContext { get; set; } // Heading, intro and buttons come from the "home" page's body. Until it is loaded - // -- and for good if PocketBase is down -- the static fallback heading stands, so - // the start page always has a title. + // -- and for good if PocketBase fails during this request -- the static fallback + // heading stands, so the start page always has a title. private HomeIntro _intro = HomeIntro.Fallback; // The hero, the tiles and the next-event card are fixed structure of the start @@ -53,8 +53,12 @@ public partial class Home } // Pages and events load side by side and fail on their own: without events the - // page loses only the card, without pages only text and tiles. Either way the - // start page stays a 200 rather than a 503 -- it is degraded, not broken. + // page loses only the card, without pages only text and tiles. A PocketBase that + // is plainly down never gets this far -- the maintenance gate in Program.cs + // answers with maintenance.html first. These catches are the second line: the + // gate's health result is cached for a few seconds, so PocketBase can fail between + // that check and these reads. Without them that would be a 500; with them the + // start page stays a 200 rather than a 503 -- degraded, not broken, and rare. private async Task LoadPagesAsync(CancellationToken token) { try diff --git a/Elternbeirat.Web/Program.cs b/Elternbeirat.Web/Program.cs index b25af41..5d6fdb9 100644 --- a/Elternbeirat.Web/Program.cs +++ b/Elternbeirat.Web/Program.cs @@ -2,6 +2,8 @@ using Elternbeirat.Contracts; using Elternbeirat.PocketBase; using Elternbeirat.Web.Components; using Elternbeirat.Web.Features.Events; +using Elternbeirat.Web.Shared; +using Microsoft.AspNetCore.Components.Endpoints; using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); @@ -25,6 +27,10 @@ builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(5); }); +// Shared by all requests, so the maintenance gate below asks PocketBase at most once +// every few seconds rather than on every page view. +builder.Services.AddSingleton(); + var app = builder.Build(); // NPM sits in front of the app and handles HTTPS. Without this line the app would @@ -48,6 +54,37 @@ if (app.Environment.IsDevelopment() is false) // is down (a 503) it would wrongly show "not found" instead of our own message. app.UseAntiforgery(); +// Maintenance gate: while PocketBase is down, every page gets the static +// maintenance.html with 503 instead of a half-empty page (the menu comes from +// PocketBase too). Routing has already picked the endpoint at this point, so the gate +// covers exactly the Razor component pages: /health, the calendar feeds, the static +// assets (incl. maintenance.html and its font) are not components and answer for +// themselves. A new endpoint is thus left alone unless it is a page. The health +// result is cached (PocketBaseHealthCache), so the page may come back a few seconds +// after PocketBase does. Retry-After tells well-behaved clients and crawlers when to +// come back; no-store keeps the 503 page out of any cache. +app.Use(async (context, next) => +{ + var isPage = context.GetEndpoint()?.Metadata.GetMetadata() is not null; + var services = context.RequestServices; + if (!isPage + || await services.GetRequiredService().IsHealthyAsync( + services.GetRequiredService().IsHealthyAsync, + context.RequestAborted)) + { + await next(context); + return; + } + + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.Headers.RetryAfter = "60"; + context.Response.Headers.CacheControl = "no-store"; + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.SendFileAsync( + app.Environment.WebRootFileProvider.GetFileInfo("maintenance.html"), + context.RequestAborted); +}); + app.MapStaticAssets(); app.MapRazorComponents(); diff --git a/Elternbeirat.Web/Shared/PocketBaseHealthCache.cs b/Elternbeirat.Web/Shared/PocketBaseHealthCache.cs new file mode 100644 index 0000000..819b1c6 --- /dev/null +++ b/Elternbeirat.Web/Shared/PocketBaseHealthCache.cs @@ -0,0 +1,73 @@ +namespace Elternbeirat.Web.Shared; + +/// +/// Remembers for a few seconds whether PocketBase answered its health check, so the +/// maintenance gate does not probe PocketBase on every page request. +/// +/// +/// The clock that decides when a result is stale; injected so tests can step past +/// without waiting. +/// +/// +/// The gate in Program.cs asks this cache before rendering any page and serves +/// the static maintenance.html with 503 while the answer is "unhealthy". A +/// failed check is kept just as long as a passed one: while PocketBase is down every +/// probe would otherwise wait for the connection to fail again. +/// +/// The price of caching is a short blind spot in both directions: after +/// PocketBase fails, pages may still render for up to (the +/// components' own fallbacks cover that), and after it recovers the maintenance +/// page may stay up for as long. +/// +/// +/// Registered as a singleton. No lock: two requests that find the result stale at +/// the same moment both probe, and the later answer wins -- harmless, and cheaper +/// than making every request wait on one another. +/// +/// +/// +/// +/// var healthy = await cache.IsHealthyAsync(pocketBase.IsHealthyAsync, context.RequestAborted); +/// +/// +/// +public sealed class PocketBaseHealthCache(TimeProvider time) +{ + /// + /// How long a health check result is reused before PocketBase is asked again. + /// + public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(10); + + // One reference, swapped as a whole, so a reader never sees the result of one + // check paired with the time of another. + private volatile Check? _last; + + /// + /// Returns the last known health of PocketBase, probing again once it is older + /// than . + /// + /// + /// The actual check, usually . + /// Passed in rather than injected because the typed client is transient and this + /// cache is a singleton. + /// + /// Cancels the probe, e.g. when the visitor goes away. + /// + /// if PocketBase answered its last check (or answers this + /// one), otherwise . + /// + /// + /// was cancelled during a probe; nothing is cached then. + /// + public async Task IsHealthyAsync(Func> probe, CancellationToken token) => + _last is { } last && time.GetUtcNow() - last.At < Lifetime + ? last.Healthy + : (_last = new Check(await probe(token), time.GetUtcNow())).Healthy; + + /// + /// One health check result and when it was taken. + /// + /// Whether PocketBase answered. + /// When the answer came in. + private sealed record Check(bool Healthy, DateTimeOffset At); +} diff --git a/Elternbeirat.Web/wwwroot/maintenance.html b/Elternbeirat.Web/wwwroot/maintenance.html new file mode 100644 index 0000000..dacb1f6 --- /dev/null +++ b/Elternbeirat.Web/wwwroot/maintenance.html @@ -0,0 +1,228 @@ + + + + + + + + + Elternbeirat der IGMH – gerade nicht erreichbar + + + + +
+
+ + + + Elternbeirat + IGMH Mannheim + +
+
+ +
+
+

Die Seite ist gerade nicht erreichbar

+

+ Wir bitten um Entschuldigung. Die Website des Elternbeirats der IGMH ist im + Moment nicht verfügbar. Wir arbeiten daran, bitte versuchen Sie es in Kürze + noch einmal. +

+

+ In dringenden Fällen erreichen Sie den Elternbeirat per E-Mail:
+ kontakt@elternbeirat-igmh.info +

+
+
+ + + diff --git a/docs/deployment.md b/docs/deployment.md index 542581d..a580c9e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -128,6 +128,31 @@ development and the tests and are **not** used on Unraid — see `entwicklung.md --- +## Outage: PocketBase unreachable + +If `eb-pocketbase` is down while `eb-blazor` runs, the app does not render +half-empty pages (the menu comes from PocketBase too). A gate in `Program.cs` +answers every **page** request with the static `wwwroot/maintenance.html`: + +| Request | Answer while PocketBase is down | +|---|---| +| Any page (`/`, `/board`, `/posts/…`, unknown slugs) | **503**, `Retry-After: 60`, maintenance page | +| `/health` | **503** `PocketBase unreachable` (for Uptime Kuma) | +| `/events.ics` | 200 with an empty calendar, so subscriptions keep working | +| `/events/{id}.ics` | 503 | +| Static files (CSS, fonts, PDFs, `/maintenance.html`) | served as usual | + +The gate caches the health check for **10 seconds** (`PocketBaseHealthCache`). So +after PocketBase stops, pages can render for up to 10 s more (the components +degrade on their own in that window: empty menu, a short note, 503), and after it +starts again the maintenance page can stay up for up to 10 s. No restart of the +web app is needed — the site comes back by itself. + +`maintenance.html` carries its styles inline, as a copy of the tokens from +`app.css`. When colours change there, copy them over. + +--- + ## Rollback `scripts/release.sh` additionally tags each release with the short commit SHA, -- 2.54.0