Skip to content
Development

Making a silent failure loud: proactive Medium session-expiry detection

By Victor Da Luz
railsrubymonitoringdev-logblog-manager

The premise: the medium-bridge’s Medium session cookies rotate in a live browser context that never gets written back to the mounted storageState.json, so every container restart reverts to an aging snapshot. Before this issue, the only way to find out the session had gone stale was a real publish failing.

Before writing anything I checked the issue’s own assumptions against the actual code, and two of them didn’t hold. The issue said Discord alerting on import failures already worked - it didn’t. alertDiscord() exists only inside the bridge’s server, gated on a DISCORD_WEBHOOK_URL env var that was never declared in the deploy config or the secrets file, so it’s a silent no-op in production today. And the issue pointed at the reconcile job as an example of the bridge-calling pattern to follow - it doesn’t call the bridge at all, it hits Medium’s public RSS feed for something unrelated. The publish job was the actual pattern to mirror. Worth catching both before building on top of a wrong mental model.

The design: a new GET /health/medium endpoint on the bridge that navigates the stored session to an authenticated page and reports whether it’s still logged in (reusing the existing isLoggedIn() check that previously only ran as a side effect of a real publish). A Rails job calls it every 6 hours, records the result on AppSetting, and a dashboard tile shows the state live via Turbo Streams. I wired the DISCORD_WEBHOOK_URL secret plumbing too, defensively - bin/rails credentials:fetch exits non-zero for a missing key, and Kamal treats a failing secrets file as fatal, so I made the fallback resolve to an empty string rather than let a missing credential break every future deploy.

I gave the health check a much shorter client-side timeout than the real import call, reasoning that a login check is one page navigation, not a full import-and-publish flow. That reasoning was wrong, and the automated multi-angle review caught it hard - five of the eight independent finder angles converged on the same bug from different directions. The server-side login check calls settle(), which retries through Cloudflare’s Turnstile challenge for up to three cycles of polling plus a reload each - comfortably past 100 seconds in the worst case, the exact scenario the file’s own comments say happens in practice. My 30-second client timeout would fire well before that finished, raising a Net::ReadTimeout that isn’t a Medium::Bridge::Error subclass - so the job’s retry_on/discard_on handlers never caught it, and the dashboard would go silently stale with zero indication anything had failed. A perfectly healthy but momentarily slow session would look identical to a genuinely dead one, and nobody would know the monitoring itself had stopped working.

The fix ended up simpler than the original design: stop trying to give the health check its own shorter timeout and just let it share the same 240-second budget import_post already uses. That single change also resolved a magic-number-drift problem the review’s altitude angle named separately - there was never a principled way to derive “30 seconds” from the server’s actual timeout constants, so removing the override removed the whole problem. I also rewrote the job’s error handling to match the publish job’s actual pattern: catch StandardError broadly inside perform itself, record the failure immediately, then re-raise - so the dashboard reflects reality on any failure, not just the two exception classes I’d originally enumerated.

The review also caught that I’d wrapped the new endpoint in the bridge’s serialize() queue - the same FIFO lane real imports and publishes use - while my own comment claimed I was matching the debug-inspect endpoint, which doesn’t serialize at all. I’d actually copied the debug-publish endpoint’s shape by mistake. A slow health check sharing that lane could delay a real, time-sensitive publish by however long the check took. Since a read-only login check never touches the publish UI, there’s no correctness reason for it to queue behind one; I dropped the wrapper to match what I’d actually intended to copy.

One more thing worth naming: the review flagged that my Turbo broadcast target was a hardcoded string instead of dom_id(record), which is a documented convention in this repo. I’d reached for a bare symbol stream name because AppSetting is a singleton and I wasn’t thinking of it as “a record with a dom_id” the way a Post obviously is - but it’s a normal ActiveRecord model with an id, and the convention applies just as well. Small fix, but a good reminder that “this record happens to be a singleton” isn’t a reason to skip the pattern everything else in the codebase follows.

What’s next: nothing blocked on this. I left two lower-severity findings undone and documented on the PR rather than expanding scope - Discord alerts have no rate-limiting or dedup, so a flapping session could re-page every six hours indefinitely, and the health check pays for a full live browser navigation when a local read of the cookie’s own expiry timestamp in storageState.json could catch the common case for near-zero cost. Both are real, neither is urgent enough to hold up detection actually landing.

Related reading

Development

The editor commit button is a deploy button

Committing a draft to main auto-deploys the blog. Once that clicked, sync-vs-async stopped being a style question - plus the legacy-affiliate carve-out a new validator almost broke.

Read