Skip to content

blog: serve last known good instead of 500 when SQLite Cloud blinks - #18

Merged
ralyodio merged 1 commit into
masterfrom
fix/blog-feed-resilience
Aug 13, 2026
Merged

blog: serve last known good instead of 500 when SQLite Cloud blinks#18
ralyodio merged 1 commit into
masterfrom
fix/blog-feed-resilience

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

The symptom

c0mpute.com/blog and /blog/rss.xml intermittently answer 500. Observed today with no deploy in between:

Probe /blog /blog/rss.xml
first 500 200 (27 items)
minutes later 200 500
6× in a row after that 200 ×6

So it is transient, and either route can be the one that fails.

listPosts() already retries once via withReconnect(), but when the retry fails too the error reaches the route and Next answers a bare 500.

Why it matters more for the feed

A blog page that fails is a page someone reloads. A feed that 500s is one every reader eventually stops polling and treats as dead — and this feed is listed in profullstack.com/feeds.opml and in the smallweb list, so the failure is visible to subscribers rather than just to visitors.

The change

Reads go through readCached(), which keeps a last-known-good copy and serves it when the database cannot be reached. An outage degrades to slightly stale posts instead of no posts.

When nothing has ever succeeded, the error is still thrown — an empty feed served as 200 would claim the blog has no posts, which is worse than an honest failure.

Two details that are load-bearing:

  • The copy is kept in process, not only in Redis. REDIS_URL is referenced exactly once in this repository — by getRedis() — and is set nowhere: no .env.example, no deploy config, no docs. Every Redis path here is already a no-op in production, so a fallback that lived only in Redis would never once be read. next start is long-running, so a Map covers exactly this failure. The Redis copy is written too, for when it is configured.
  • The Redis copy is under blog:stale:, not blog:list:. cacheDelListKeys() wipes blog:list:* on every publish, which would throw the safety net away at the precise moment the site is being written to.

The in-process copy is used only on the failure path, never as the happy-path cache: publishing invalidates through Redis, so an in-process fresh cache would hide new posts for a full TTL.

getPost() is left alone — it deliberately does not cache misses, and a single post page failing does not make a feed look dead.

Root cause, not fixed here

This is SQLite Cloud refusing connections. c0upons hit the same provider's limits and migrated to Turso. This PR makes the blog survive the blips; it does not change the database. Worth deciding separately whether c0mpute follows c0upons off SQLite Cloud.

Verification

  • tsc --noEmit clean.
  • next build succeeds; /blog, /blog/[slug] and /blog/rss.xml are still dynamic.
  • No test suite exists in this repo (CI runs codeql/security/threatcrush only), so no tests were added — flagging rather than inventing a harness in a drive-by fix.

🤖 Generated with Claude Code

c0mpute.com/blog and /blog/rss.xml intermittently answer 500 — observed
returning 500 and 200 minutes apart with no deploy in between, sometimes the
page failing while the feed succeeded and sometimes the reverse. listPosts()
already retries once through withReconnect(), but when the retry fails too the
error reaches the route and Next answers a bare 500.

That matters most for the feed. A blog page that fails is a page someone
reloads; a feed that 500s is one every reader eventually stops polling and
treats as dead, and this feed is listed in profullstack.com/feeds.opml and in
the smallweb list.

So reads go through readCached(), which keeps a last-known-good copy and serves
it when the database cannot be reached. An outage now degrades to slightly
stale posts rather than to no posts. When nothing has ever succeeded the error
is still thrown, because an empty feed served as 200 would claim the blog has
no posts at all.

The copy is kept in process, not only in Redis. REDIS_URL is referenced exactly
once in this repository — by getRedis() — and set nowhere: no .env.example, no
deploy config, no documentation. Every Redis path here is already a no-op in
production, so a fallback that lived only in Redis would never once be read.
`next start` is long-running, so a Map covers precisely the failure this is for.
The Redis copy is written too, under `blog:stale:` rather than `blog:list:`,
because cacheDelListKeys() wipes the latter on every publish and would throw
the safety net away at the moment the site is being written to.

Used only on the failure path, never as the happy-path cache: publishing
invalidates through Redis, so an in-process fresh cache would hide new posts
for a full TTL.

getPost() is left alone — it deliberately does not cache misses, and a single
post page failing does not make a feed look dead.

Verified: tsc --noEmit clean, next build succeeds, blog routes still dynamic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

8 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 3

Severity Rule Location
HIGH sh-remote-script-execution scripts/dev-setup.sh:25
HIGH sh-remote-script-execution scripts/install.sh:159
HIGH sh-remote-script-execution scripts/install.sh:184
HIGH sh-remote-script-execution scripts/install.sh:277
HIGH sh-remote-script-execution scripts/install.sh:294
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:53
MEDIUM js-unescaped-html-sink apps/web/src/app/layout.tsx:89
MEDIUM sh-eval-expansion scripts/dev-setup.sh:35

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 091ae23 into master Aug 13, 2026
10 checks passed
ralyodio added a commit that referenced this pull request Aug 13, 2026
No Rust changed since v0.2.25. Every commit in this range is web, CI or docs,
so the binaries this tag builds are identical to the previous ones and the
version marks the site rather than the client:

  - blog: serve last known good instead of 500 when SQLite Cloud blinks (#18)
  - ci: add ThreatCrush security scan (#17)
  - feat(web): WebMCP callout on the homepage (#16)
  - docs(web): document WebMCP tools (#15)
  - feat(web): WebMCP support on c0mpute.com (#14)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant