Serve 503 when PocketBase is unavailable across all content pages

This commit is contained in:
tleininger committed 2026-09-24 13:00:14 +02:00
1 parent 0f7e968c16
commit e242eaf79a
25 files changed
+492 -253

No files matched your search

+38 -10
View File
@@ -38,14 +38,11 @@ public sealed class PocketBaseClient(HttpClient httpClient)
/// <returns>
/// The public pages, or an empty list if there are none.
/// </returns>
/// <exception cref="HttpRequestException">
/// PocketBase could not be reached or returned a non-success status code.
/// </exception>
/// <exception cref="JsonException">
/// The response could not be deserialized.
/// <exception cref="PocketBaseUnavailableException">
/// PocketBase could not be reached, timed out, or returned an unreadable response.
/// </exception>
/// <exception cref="OperationCanceledException">
/// <paramref name="token"/> was canceled or the request timed out.
/// <paramref name="token"/> was canceled by the caller.
/// </exception>
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken token = default)
=> GetRecordsAsync<Page>("pages", "order", token);
@@ -73,15 +70,22 @@ public sealed class PocketBaseClient(HttpClient httpClient)
=> GetRecordsAsync<Event>("events", "start", token);
/// <summary>
/// Gets all public FAQ entries, ordered by <see cref="Faq.Topic"/>.
/// Gets all public FAQ entries, ordered by <see cref="Faq.Topic"/>, then by
/// <see cref="Faq.Question"/>.
/// </summary>
/// <remarks>
/// The second sort key makes the order total: sorting by topic alone leaves
/// entries within a topic in an undefined order, so the same content could
/// render differently between requests. Adding the question as a tie-breaker
/// keeps the FAQ list and any teaser drawn from it stable.
/// </remarks>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public FAQ entries, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken token = default)
=> GetRecordsAsync<Faq>("faqs", "topic", token);
=> GetRecordsAsync<Faq>("faqs", "topic,question", token);
/// <summary>
/// Gets all public records of a collection in a single request.
@@ -101,6 +105,12 @@ public sealed class PocketBaseClient(HttpClient httpClient)
/// <returns>
/// The public records, or an empty list if there are none.
/// </returns>
/// <exception cref="PocketBaseUnavailableException">
/// PocketBase could not be reached, timed out, or returned an unreadable response.
/// </exception>
/// <exception cref="OperationCanceledException">
/// <paramref name="token"/> was canceled by the caller.
/// </exception>
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(
string collection,
string sort,
@@ -112,7 +122,25 @@ public sealed class PocketBaseClient(HttpClient httpClient)
+ $"?perPage=500&filter={Uri.EscapeDataString("public=true")}"
+ $"&sort={Uri.EscapeDataString(sort)}";
var result = await httpClient.GetFromJsonAsync<RecordList<T>>(url, token);
return result?.Items ?? [];
try
{
var result = await httpClient.GetFromJsonAsync<RecordList<T>>(url, token);
return result?.Items ?? [];
}
// These three mean PocketBase itself failed: it was unreachable
// (HttpRequestException), the request to it timed out (a TaskCanceledException
// whose inner exception is a TimeoutException, which is how HttpClient surfaces
// its own timeout), or it answered with something we could not read
// (JsonException). A plain TaskCanceledException with no inner TimeoutException
// is the caller's own cancellation via token and is deliberately not caught --
// it propagates as OperationCanceledException so an aborted request stays an
// abort, not a fault.
catch (Exception exception) when (
exception is HttpRequestException
or JsonException
or TaskCanceledException { InnerException: TimeoutException })
{
throw new PocketBaseUnavailableException($"Could not read collection '{collection}'.", exception);
}
}
}
@@ -0,0 +1,49 @@
namespace Elternbeirat.PocketBase;
/// <summary>
/// Thrown when PocketBase could not be reached, timed out, or returned a
/// response that could not be read.
/// </summary>
/// <remarks>
/// This is the one failure the content pages need to distinguish: the site is
/// up but its data store is not, which is an HTTP 503, not a 404. Which
/// low-level exceptions mean that (a transport error, a timeout, unreadable
/// JSON) is <see cref="PocketBaseClient"/>'s concern; callers catch only this
/// type and need not know how the client talks to PocketBase.
/// </remarks>
public sealed class PocketBaseUnavailableException : Exception
{
/// <summary>
/// Initializes a new instance with a default message.
/// </summary>
public PocketBaseUnavailableException()
: base("PocketBase is unavailable.")
{
}
/// <summary>
/// Initializes a new instance with the given message.
/// </summary>
/// <param name="message">
/// The message that describes the error.
/// </param>
public PocketBaseUnavailableException(string message)
: base(message)
{
}
/// <summary>
/// Initializes a new instance with the given message and the underlying
/// failure that caused it.
/// </summary>
/// <param name="message">
/// The message that describes the error.
/// </param>
/// <param name="innerException">
/// The transport, timeout, or deserialization exception that was caught.
/// </param>
public PocketBaseUnavailableException(string message, Exception innerException)
: base(message, innerException)
{
}
}