Distinguish four og:image outcomes to prevent blank cards - #232
Merged
Conversation
`/feeds/visual-preview` のカードは、og:image の解決を 1 回しか要求しない。 `ResolveOgImages` は per-host スロットの内側で出版社のページを同期に取得するため、 1 回の RPC で終わらなかったバッチは読者側のリクエストだけが死に、サーバが到達できた フィードの画像はストアに書かれる。カードはそれを「オリジンの回答」として読み、 セッションが終わるまで "No preview" を出し続けていた。画像は再取得 1 回ぶんの距離に あり、リロードすれば出る — つまり UI だけが更新されていなかった。 - `ogImageResolver` の答えを `resolved` / `absent` / `unavailable` の 3 値にする。 記憶するのはサーバが答えた前 2 者だけで、「訊けなかった」は誰も記憶しない (RFC 2308 が negative answer と server failure に別々の TTL を与えるのと同じ線)。 分類は Connect code と `X-Alt-Failure-Scope` で行い、メッセージ文字列では行わない。 出版社自身が出した 429 ([[000963]] が scope=host を刻む) は再送しない。 - `createProxyImage` に上限つきの retry ladder を入れる。Full jitter、計 3 回まで。 再取得はストアから答えられるので出版社へのリクエストは増えない。届かない バックエンドはシマーではなく fallback に着地する。 - 遅れて届いた URL でロード済みの画像を捨てないようにする。同じフィードの URL が 後から埋まっただけのときに object URL を revoke して再取得していた。 - ページ側の backfill をフィードオブジェクトへの代入から article キーの overlay に 変える。await をまたいで掴んでいた配列は撮った時点のスナップショットで、SSR 由来の プレーンオブジェクトや `$derived` が作り直したオブジェクトへの代入は画面に出ない。 overlay は SvelteMap ではなくセル単位 — 不在キーの `get` は map 全体の version を 購読するため、冷えたグリッドの流し込みが O(N^2) になる。 - シマーを無限ループから 3 周に変える (WCAG 2.2.2)。到着時のフェードと `prefers-reduced-motion`、`aria-busy` を追加。 Tests: unit 1549 + browser 543 + E2E 12 green、svelte-check 0 errors。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCVkXfwJMinQZ7pQwimg8M
`ResolveOgImages` は解決できたフィードだけを返し、それ以外は「不在」で表していた。 だがサーバ内部の未解決には 4 通りの意味があり、それぞれ retry 方針が違う (`domain.OgImageRefusal.RetryAfter()`): robots disallow / og タグ無し / 404 は保持窓の 内側で恒久、403 は 24h、fetch_error は 6h、page URL 不正は何も記録せず `NeedsFetch()` は true のまま。クライアントはこれを全部「不在」として受け取り、セッション中ずっと再問い合わせ しないため、サーバが「あとで訊き直してよい」と判断したケースでもカードが blank で固定される。 - `ResolveOgImagesResponse` に `repeated UnresolvedOgImage unresolved = 2` を加算。 `images` に居る / `unresolved` に居て retry==0 / `unresolved` に居て retry>0 / どちらにも居ない、の 4 状態を表す。4 つ目が enum 無しで意味を持てることが、1 リスト + status enum ではなく 2 リストを選んだ理由 — enum ならバッチ上限で切り詰めた分にまで 「何もしなかった」と言うための行を出す義務が生じる。 - 既存の設計意図「A feed whose origin refused is absent rather than present-with-an-empty-URL」は覆さず維持。`unresolved` はその不在がどの種類かを言うだけ。 - datahub の `FeedOgImageTarget` に `attempts = 5` を加算。バーを試行回数に連動させる ための材料で、列は既に `attempts + 1` で書かれていながらどこからも読まれていなかった。 - `SaveFeedOgImageRequest.retry_after_seconds` は絶対バーの意味のまま。既存フィールドの 意味を書き換えるとローリングデプロイで新旧が混ざったとき黙って壊れるため、意図的に据え置き。 すべて純加算で `buf breaking` 通過。`go build ./...` と svelte-check は変更前と同一。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCVkXfwJMinQZ7pQwimg8M
クライアントは「サーバが URL をくれなかった」を 1 つの意味として扱い、セッション中ずっと
記憶していた。実際にはサーバは 4 通りの答えを持っていて、そのうち 3 つは「あとで訊き直して
よい」を意味する。カードが blank のまま固定されるのはこの取り違えによる。
`outcomeFor` が 2 本のリストから 4 状態を読む:
- `images` に居る → 解決。両リストに居ても解決を優先する
- `unresolved` にバー 0 → オリジンに訊いて断られた。保持窓の内側では確定。記憶する
- `unresolved` にバーが天井以内 → 失敗しただけで、バーが明ければ成功しうる。記憶せず、
バーを ladder の床として渡す
- `unresolved` にバーが天井超え → 確定として畳む。待たないと決めた時間だけ shimmer を
出し続けるのは、読者にも、実際には何も要求していない出版社にも嘘になる。サーバ側の行は
残るので、バーを実際に越えた時計を持つ後のセッションが回収する
- どちらにも居ない → サーバが到達していない。オリジンへのリクエストは 1 本も出ていないので
記憶せず訊き直す
transport failure を分類する `classifyFailure` は無改変。あれは「訊けなかった」の軸で、
今回足したのは「訊いたが解決しなかった」の軸。2 つは独立している。
`retry_after_seconds` は protobuf-es が int64 を bigint にするため `ogImages.ts` で ms の
number に変換し、上位に bigint を漏らさない。ここで `if (!retryAfterSeconds)` と書くと
バー 0(確定)が「到達せず」(即再試行可)に化けて意味が正反対になるため、テストで固定した。
`unresolved` は純加算フィールドなので `?? []` でガードしている。フィールドを知らない旧サーバの
応答が通る経路のためで、無言フォールバックではなく後方互換。
E2E の「an unresolvable feed settles on the fallback」は `{images: []}` を「断られた」の
つもりで返していたが、新契約ではそれは「到達せず」を意味する。テスト自身のコメントが
「The origin refused ... The server records the failure」と書いているので、モックを
`unresolved: [{feedId, retryAfterSeconds: "0"}]` に更新して意図と一致させた。ask 回数の
上限アサーションも足してあり、これが通ることで Connect JSON の int64 文字列表現が正しく
パースされ `unresolved` が実際に効いていることが E2E で証明される。
Tests: server 1562 / browser 41 / svelte-check 0 errors 0 warnings。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCVkXfwJMinQZ7pQwimg8M
`ResolveOgImages` は解決できたフィードだけを返していた。サーバ内部には未解決の意味が 4 通りあり retry 方針も違うのに、ワイヤ上はすべて「不在」に潰れ、クライアントは セッション中ずっと再問い合わせしない。サーバが「あとで訊き直してよい」と判断した ケースでもカードが blank で固定されるのはこのため。 - `OgImageRefusal.RetryAfter()` に attempts を足す。エスカレートするのは一時的障害 (`fetch_error`) だけで `min(6h, 5s × 2^(attempts-1))`。robots_disallow / no_og_tag / 404 は 0 のまま — これらはオリジンの答えであって到達途中の障害ではなく、4 回聞いても 答えが変わりやすくなるわけではない。エスカレートさせるとそれを示唆してしまう。403 は 24h 固定のまま(誤設定 edge を 1 日 1 回拾うという既存の根拠が、まさにエスカレーションで 壊れる。保持窓 7 日が総回数を 7 に抑える) - base が 5s なのはワイヤの向こう側の天井による。クライアントは 10 秒を超えるバーを 待てない(`ogImageRetry.ts` の `OG_RETRY_CEILING_MS`)。30s から始めると初回から 天井超えで、クライアントの再問い合わせが一度も回らない - `FeedOgImageTarget` に `Attempts` と `RetryAfterSeconds`。2 回目以降は失敗中の feed が 必ず suppressed になるので、`Suppressed` だけ持つ呼び出し側は「今は無理」より precise な ことを言えず、5 秒のバーで止まっているだけのカードがセッション中見捨てられる - driver の upsert が成功時も無条件に `attempts + 1` していたのを直す。順調に解決し続けて いた feed が初めて 1 回失敗した瞬間に最大バーを食らっていた - usecase は `unresolved` を組み立てる。malformed URL / SSRF 拒否はどちらのリストにも 入れない(「our problem, not the origin's answer」という既存の決定を維持) CDC は producer GREEN より先に RED を置いた(CLAUDE.md ルール 7)。 Tests: `go test ./...` 全通過、gofmt / vet clean。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCVkXfwJMinQZ7pQwimg8M
`ResolveOgImagesResponse` の 4 状態表と実装が食い違っていた。proto コメントは 「page URL が使えないものは両リスト不在」と書いていたが、実装は `PageURL == ""` を `unresolved`/0 に入れていた。そして `fetchErr != nil`(URL 不正 / SSRF 拒否)だけが 沈黙に落ちていた。 不在は「サーバがその feed を検討すらしていない」だけを意味することにする。バッチ上限で 切り詰められた分と、target クエリが行を返さなかった分。**検討したうえで解決できなかった ものは、理由が何であれ `unresolved` に入る。** message 名 `UnresolvedOgImage`("one feed the server considered and could not resolve") とも一致する。検討した結果を沈黙で表すと、絶対に解決しない feed をクライアントが毎ページ ロード訊き直し続ける — 不在は「未検討」を意味するので、クライアントは記憶しないため。 DB に refusal を記録しない既存の決定は変えていない。書くものと答えるものは別の質問で、 今回変えたのは後者だけ: 行は書かないが、この読者のセッションに対しては settled と答える。 page URL はこの保持窓の内側で変わらないし、それに対する我々の判断も変わらないので、 次に訊いてもまったく同じ場所で失敗する。 Tests: `go test ./...` 該当パッケージ通過、gofmt / vet / buf lint clean、 frontend の resolver / ogImages テストも通過(生成物の差分はコメントのみ)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCVkXfwJMinQZ7pQwimg8M
`src/test/contracts/*.test.ts` はこのリポジトリが frontend ↔ backend の契約に選んだ守り
であり、その目的をこう明言している —「when the proto changes, these tests break before
the E2E tests silently pass with stale mock data」。
`ResolveOgImages` だけがこの守りに一度も入っていなかった。パスは `CONNECT_RPC_PATHS` に
登録されているのに、レスポンス形状は proto に対して検証されていない。そして現に今回、
`unresolved` を足したことでモックの `{images: []}` の意味が「断られた」から「検討すら
していない」に変わり、E2E スペックを手で直して初めて気づいた。契約テストがあれば手より先に
落ちていた。
- E2E スペックにインラインで散っていたレスポンスを `buildResolveOgImagesResponse()` に
引き上げた。契約テストは「E2E が実際に使うモック」を検証してこそ意味があり、スペックに
インラインで残っている限り別物を検証することになる
- `create()` ではなく `fromJson()` で検証する。`create()` は TS の init オブジェクトを取り、
未知プロパティを黙って捨て、int64 に生の number を受けて `toBinary` が黙って強制変換する
ため、今回のドリフト 2 種のどちらも見えない。`fromJson` は未知キーを拒否し、Playwright が
返すこのボディをブラウザの Connect クライアントが実際に通す経路でもある
- protobuf JSON は int64 に `"20"` と `20` の両方を受けるので、文字列形式は decoder ではなく
フィクスチャへの明示的なアサーションで固定した
`retry_after_seconds: 0` の round-trip も確認した。`toJson` は proto3 の default 省略で 0 を
落とすが、`UnresolvedOgImage` の**行そのものは残る**。`outcomeFor` は Map のメンバーシップで
判定していてフィールドの presence を見ていないので、省略で「確定・バー 0」が「未検討」に
化けることはない。status enum ではなく 2 リストを選んだことで買えている性質で、テストで固定した。
守りが効くことは注入で確認済み: 確定行を落とすと 3 本、int64 を数値で書くと 1 本落ちる。
Tests: server 152 files / 1568 passed、svelte-check 0 errors、E2E 該当 2 スペック 5 passed。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCVkXfwJMinQZ7pQwimg8M
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Refactors the og:image resolution pipeline to distinguish four distinct outcomes (resolved, settled refusal, transient failure, never reached) instead of collapsing them into a binary present/absent answer. This prevents cards from being permanently blanked when a resolution request times out but the image was successfully stored server-side.
Problem
When
ResolveOgImagesfetches a publisher's page inline behind a per-host slot, a batch that cannot finish within one RPC leaves the reader's request dead while images the server did reach are already written to the store. The old code treated "request timed out" as "origin has no image", leaving cards blank for the session even though the image existed one cheap re-ask away.Key Changes
Protocol & Wire Contract
UnresolvedOgImageproto message carryingfeed_idandretry_after_secondsto distinguish refusal typesResolveOgImagesResponseinto two parallel lists:images(resolved) andunresolved(refused or failed)Frontend Resolution Logic
OgImageOutcometype with three states:resolved(has URL),absent(settled refusal),unavailable(transient failure with optional retry bar)ogImageResolvernow returns outcomes instead of raw URLs, allowing callers to distinguish "ask again later" from "this feed has no image"ogImageRetrymodule with exponential backoff ladder (capped atOG_RETRY_CEILING_MS) and full jitter to spread retries across the windowBackend Retry Tracking
FeedOgImageTargetnow carriesAttempts(how many resolution attempts spent) andRetryAfterSeconds(remaining bar from last refusal)SaveFeedOgImagetakes pre-computedretryAfterduration to ensure reader and server agree on the same barOgImageRefusal.RetryAfter(attempts)implements escalation logic: settled answers (robots.txt, no og:image tag, 404) stay permanent; transient failures escalate exponentially from a 5s base, capped at 6h; 403 stays flat at 24hStorage & Projection
feed_og_images.attemptsandfeed_og_images.retry_afteralready existed (20260811000000_create_feed_og_images.sql).attemptswas incremented on every upsert and read by nothing in the codebase — this PR starts reading it, and resets it to 1 on a resolution so a feed that succeeds for months does not carry a grown counter into its first failure.retry_after's remaining seconds are now surfaced to the callerFeedOgImageTarget, not on the tablealt-backend↔alt-data-hub) updated to verify the counters survive the mappingTesting
unresolvedrow — the property that makes two lists work where a status enum would notvisual-preview-late-og-resolve.spec.tsverifies cards fill in when resolution completes after request timeout, and that the fallback is never shown on the wayog_image_resolve_test.goverifies all four outcomes are emitted correctlyImplementation Details
MAX_RESOLVE_ATTEMPTS(3) with full jitter. The server's base bar for a transient failure is 5s deliberately:OG_RETRY_CEILING_MSis 10s, so a longer base would put the first retry beyond what a browser tab will wait for and the client ladder would never run. A bar past the ceiling folds the card to its fallback rather than shimmering at a wait we have already decided not to honour.ogImageOverlayholds URLs that arrived after cards were rendered, keyed by article id. It is a plainMapof one-field$statecells rather than aSvelteMap:SvelteMap.get()on an absent key subscribes to the whole-map version signal, and the firstset()for any key bumps it, so a cold grid of N cards filling in costs O(N²) re-renders.CDC scope
Rule 7 (
CLAUDE.md) is satisfied at the boundary it is about.alt-backend↔alt-data-hub(GetFeedOgImageTargets) has a real Pact RED→GREEN — removingretryAfterSecondsfrom the provider stub fails verification. The browser leg has no Pact and does not need one:alt-frontend-svis not a pacticipant in this repo, the SvelteKit/api/v2route is a byte-forwarder that never decodes the message, and both sides are generated from one proto in one commit — so the DI-wiring failure mode ADR-000928 describes cannot occur there. That leg is guarded bysrc/test/contracts/, which this PR extends to coverResolveOgImagesfor the first time.https://claude.ai/code/session_01LCVkXfwJMinQZ7pQwimg8M