紡Book の勉強ノートに戻る
コードレビュー学習2026.06.26

時間で切れるリンクで写真を配ると、切れた瞬間が無言になる — 失効する配信と受け皿

堅牢性署名付きURLエラーハンドリング

Codex と Claude のレビューで浮かんだ「時間で失効する配信」まわりの観察を、勉強のためにまとめたものです。

写真日記アプリ(綴Photo)の共有ページは、写真本体を「一定時間で失効する署名付きURL」で配信しています。受け手は技術に詳しくない大切な人を想定しています。このページのレビューで浮かんだのは、失効そのものより「失効した後どうなるか」の受け皿だ。ここではその一点に絞って、一般論として観察する。

この記事で扱う観察ポイント

  • そもそも「署名付きURL」が時間で失効するとはどういうことか
  • 失効を拾う受け皿が無いと、失効が「画像が無言で割れる」という形で受け手に届きます
  • 受け皿(失敗の検知・取り直し・案内)を、本体と同じ重さで設計するという発想

便利な仕組みは、たいてい裏側に「異常系」を連れてきます。今回はその一例として「時間で失効する配信」を見る。


写真ファイルはオブジェクトストレージに残る。署名URLは photos_photo に保存せず、解錠時にブラウザのメモリへ乗る。オブジェクトストレージ写真ファイル閉じても残る外からは直接開けないphotos_photo.image倉庫のパスPostgreSQL。閉じても残る署名URLは保存しないブラウザのメモリ解錠後の署名URL発行されるだけ。保存しない30分で失効する解錠の瞬間に署名URLを発行して画面の <img> へ渡す交差。置き場所ではない。URL 自体はメモリにしか無い

コードで起きていたこと

問題は、画像の読み込みが失敗したときに何もしていない点にある。読み込み失敗を拾う onError のような受け皿が、共有の画像表示まわりに見当たらなかった。

jsx
// 現状の構造(匿名化した疑似コード)
// 解錠時に、その時点で有効な署名URLが写真ぶんまとめて画面に渡る
const photos = await unlockSharedPage(token, passphrase)
 
// 画像はURLを受け取って表示するだけ。
// 読み込みが失敗しても、それを拾う受け皿がどこにも無い
photos.map((p) => <PhotoImage src={p.signedUrl} />)
切れたのはメモリの署名URLだけ。オブジェクトストレージの写真は残っている。保存はしていない。解錠直後メモリのURLは有効写真が出る倉庫は閉じたまま開きっぱなし通信遅延30分の境界をまたぐ同じURLでは開かない失効後割れアイコン・真っ白受け皿が無いアプリは知らず取り直さない倉庫の写真は残っている。切れたのはブラウザメモリのURLだけ失敗はアプリにとって存在しない

受け手の画面には理由が出ません。残るのは「壊れたのか? 自分の通信のせいか? もう見られないのか?」という宙ぶらりんだけです。共有ページにとっての核は写真そのものなので、その核が無言で欠けます。

なお、画像の表示を画像最適化のレイヤー(フレームワークの画像コンポーネント等)に任せている場合、ブラウザが署名URLを直接踏むとは限らず、割れ方や失敗の見え方はその経路に左右されます。それでも「失効しうるURLを、失敗の受け皿なしで渡している」という構造そのものは変わりません。

なぜそうなりやすいか

「成功するパス」だけを見ているとこの穴は見えません。開発中は通信が速く、解錠してすぐ写真が出るので、失効の境界をまたぐ状況に出くわしにくいです。失効は「時間が経つ」「通信が遅い」という条件が揃ったときだけ表に出るので、正常系のテストをすり抜けます。

加えて、署名URLは「取得した瞬間は確実に有効」なので、取得処理を書いた直後は何も問題が無いように見えます。有効期限という時限装置が仕込まれていることが、コードの見た目からは伝わってきません。これは特定の誰かのうっかりではなく、「一時的に有効なもの」を「ずっと有効なもの」と同じ書き方で扱うと、誰でもハマる構造です。

改善の方向

失効する配信を使うなら、失効したときの受け皿を必ずセットで用意します。考え方は3つに分けられます。下は、考え方を示すための一般的な疑似コードで、特定の実装そのものではありません。

jsx
// 改善の方向(考え方を示す一般的な疑似コード)
function SharedImage({ getFreshUrl, initialUrl }) {
  const [url, setUrl] = useState(initialUrl)
  const retryCount = useRef(0) // 再取得した回数。画面に出さない裏メモなので useRef
 
  async function handleError() {
    // 再取得は上限を切って、無限ループを防ぐ
    if (retryCount.current >= MAX_RETRY) {
      setUrl(null) // これ以上は静かなプレースホルダに切り替え
      return
    }
    retryCount.current += 1
    // 解錠済みの権限を手元に保持しておき、新しい署名URLを取り直す
    const fresh = await getFreshUrl()
    setUrl(fresh)
  }
 
  if (!url) return <Placeholder text="写真を読み込めませんでした" />
  // onError = Callout で説明した「写真が出せなかったときの貼り紙」= これが受け皿
  return <img src={url} onError={handleError} />
}
表示中のURLは useState(メモリ・画面に出る)。再取得回数は useRef(メモリ・画面に出さない)。どちらも保存しない。画面の <img>src はメモリの署名URLonError受け皿交差。置き場所ではないuseState表示中のURL画面に出る。保存しないuseRef再取得した回数裏メモ。画面に出さない。保存しない上限未満新しい署名URLを useState へ上限到達静かなプレースホルダへ切る

拾わなければ、失効はアプリにとって存在しないことになります。取り直しは解錠済みの権限を手元に保持して行うので、受け手に再入力を求めなくていいです。取り直し方は実装次第で、写真単位の取得口を持つなら個別に、持たないなら解錠の手順をもう一度通して新しいURL一式を得ます。取り直してもまた失敗する状況(倉庫側が落ちている等)では、上限に達したら静かなプレースホルダに切り替え、無限に取り直し続けません。

学びポイント

時間で失効する配信には、失効時の受け皿(失敗の検知・再取得・案内)を必ずセットで設計します。受け皿が無いと、失効は「写真が無言で割れる」という最悪の形で受け手に届きます。受け取り手が非エンジニアなら、なおさら「割れた絵」ではなく「読み込めませんでした」という言葉と、できれば自動の取り直しを見せたい。


このテーマの括り

この観察は「便利な仕組みは、裏側に異常系を連れてくる」という一般則の一例です。

署名URLのように時間で失効する仕組みは、私的な写真を「鍵をかけたまま、一定時間だけ開けて渡す」便利さがあります。その代わり、必ず「失効した後」という異常系を連れてきます。便利さだけを実装して受け皿を後回しにすると、ある瞬間から先は「理由の出ない欠け」が受け手に押し付けられます。

仕組み 連れてくる異常系 必要な受け皿
時間で失効する署名URL 開きっぱなし・通信遅延で失効 失敗の検知 + 自動の再取得 + 案内

便利さと受け皿は、本来セットで設計するもの——というのが、この種の穴から取り出せる原理です。

おわりに

この種の穴は、「正常に動くところまで作って、そこで完成とみなした」ときに出やすいです。失効・遅延といった異常系は、開発中は自然には起きないので、意図的に作り出さないと表に出ません。とくに受け取り手が非エンジニアの場合、欠けは「割れた絵」という最も不親切な形で届きます。失効する配信を入れるなら、失効した瞬間に何が見えるかを、本体と同じ重さで設計しておきたい。

この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。