Skip to content

Stop losing a post's images when the image provider blinks - #223

Merged
ralyodio merged 1 commit into
masterfrom
worktree-autoblog-image-retry
Aug 30, 2026
Merged

Stop losing a post's images when the image provider blinks#223
ralyodio merged 1 commit into
masterfrom
worktree-autoblog-image-retry

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What broke

Every autoblog post published since 2026-08-24 went out with no hero image and no inline art. Confirmed in prod:

posts with hero with inline
through 2026-08-19 15/day 100% 100%
2026-08-24 / 08-25 4 0 0

Uploads to the lx-article-images bucket went from 60/day (15 articles x 4 images) to zero.

Why

gpt-image-2 is the only thing in the pipeline that draws. The article text falls back openai -> anthropic, but an image failure has nowhere to go, so every image call is wrapped in a try/catch that warns and carries on — losing an illustration shouldn't cost the article.

That trade is right. The silence around it was not:

  • OpenAI's quota lapsed for a few days. Text survived on the Anthropic path; images had no second provider and all four per post failed together.
  • The only trace was a console.warn on the worker. Nothing in the database said which posts lost their art or why.
  • A failed inline image had its <!--INLINE_IMAGE_N--> marker deleted, throwing away both where the image belonged and the brief it would have been drawn from. Once the provider recovered, those images were unrecoverable.

Both providers were verified with the exact production keys: Anthropic is spend-capped (HTTP 400) until 2026-09-01; OpenAI works, including gpt-image-2 at the production params (1536x1024, quality high) and on the real hero prompts of the affected posts. So the outage has passed — but nothing would have refilled the images.

Changes

  • A failed inline image leaves a self-describing PENDING marker instead of a hole. It's an HTML comment, so readers see nothing, but it carries the ordinal, kind, alt and prompt needed to draw it later. The ordinal names the object in storage, so repairing image 2 can't overwrite image 1.
  • Reasons land on lx_article.generation_error — a column that already existed and was never written. "Which posts lost art, and why" becomes a query.
  • repairMissingArticleImages() backfills them. The hero prompt derives entirely from stored columns and pending markers carry their own brief, so images stay recoverable as long as the row does. The worker sweep calls it two articles at a time (four high 1536x1024 renders is real money and ~1min of wall clock).

None of this makes an image failure fatal. A provider outage becomes a delay rather than a permanent hole.

Already done by hand

The five affected posts within the repair window have had their heroes generated and backfilled; image_url is null is now 0 for the last 45 days. Their inline images predate the marker fix and are not recoverable.

Testing

  • tests/autoblog-image-repair.test.ts covers the marker round-trip, the --/quote escaping that would otherwise close the comment early and leak prompt text into the post, ordinal preservation, and the empty-prompt and no-marker cases. All 13 assertions were run against the real helper source.
  • The repair's PostgREST query was verified against production and returned exactly the affected rows. It caught a real bug on the way: lx_article has three FKs to lx_site, so the embed had to name lx_site!lx_article_site_id_fkey.
  • No local tsc/vitest in this checkout (known); CI gates the typecheck and suite.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RL1QYJhEEqWzs7FsQQBAMG

Every post published since 2026-08-24 went out with no hero and no inline
art. Nothing was broken in the image code and nothing was in the database
to say so: gpt-image-2 is the only thing in the pipeline that draws, and
unlike the article text — which falls back openai -> anthropic — an image
failure has nowhere to go. So each call is wrapped in a try/catch that
warns and carries on, because losing an illustration should not cost the
article.

That trade was right; the silence around it was not. OpenAI's quota lapsed
for a few days, four posts published image-less, the only trace was a
console.warn on a worker, and the first report came from someone looking
at the blog. Worse, a failed inline image had its <!--INLINE_IMAGE_N-->
marker deleted, which threw away both where the image belonged and the
brief it would have been drawn from — so even once the provider recovered,
those images were gone for good.

Three changes, none of which make an image failure fatal:

- A failed inline image now leaves a self-describing PENDING marker
  instead of a hole. It is an HTML comment, so readers see nothing, but it
  carries the ordinal, kind, alt and prompt needed to draw it later. The
  ordinal matters: it names the object in storage, so repairing image 2
  cannot overwrite image 1.

- The reasons land on lx_article.generation_error — a column that already
  existed and was never written — so "which posts lost art, and why" is a
  query instead of a log trawl nobody runs.

- repairMissingArticleImages() backfills them. The hero prompt derives
  entirely from stored columns, and pending inline markers carry their own
  brief, so images stay recoverable for as long as the row does. The
  worker sweep calls it two articles at a time, since four "high"
  1536x1024 renders is real money and about a minute of wall clock.

A provider outage is now a delay rather than a permanent hole.

The five posts already affected have had their heroes generated and
backfilled by hand; their inline images predate the marker fix and are not
recoverable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL1QYJhEEqWzs7FsQQBAMG
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

40 finding(s)

HIGH/CRITICAL: 3 | MEDIUM: 28 | LOW: 9

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:47
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:98
MEDIUM redos-nested-quantifier lib/sp/parseHandle.ts:92
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:163
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit c86d574 into master Aug 30, 2026
10 checks passed
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