Crashlytics で上がってきたクラッシュの、スタックトレースの一番上が表示直前の変換関数でした。ネットワーク層でも、レスポンスの復号でもありません。カテゴリ名を画面のラベルへ移す、十数行の関数です。
原因は、その関数から遠く離れた場所にありました。サーバー側で古いモデル ID を後継へ読み替えていて、後継が返すカテゴリ体系が増えていたのです。
読み替えは親切のつもりでした。廃止されたモデルを指したリクエストが 410 で落ちるより、後継につないだほうが動き続けます。実際、ほとんどのリクエストは動き続けていました。だから見つからなかったのだと、後になって理解しました。
「ほとんど動く」がいちばん見つけにくい状態です
古いクライアントが期待していたカテゴリは6種類で、後継モデルは8種類を返すようになっていました。増えた2つは全体の一部にしか現れません。手元で分布を再現して数えてみます。
import json, random
V12 = { "nature" , "abstract" , "city" , "animal" , "art" , "minimal" } # 旧クライアントの体系
V20 = list (V12) + [ "japanese" , "texture" ] # 後継モデルの体系
def client_v12_parse (body):
obj = json.loads(body) # 層1: 転送と JSON
text = obj[ "candidates" ][ 0 ][ "content" ][ "parts" ][ 0 ][ "text" ]
rec = json.loads(text) # 層2: 構造化出力
if rec[ "category" ] not in V12: # 層3: 表示直前の変換
raise ValueError ( "unknown category: %s " % rec[ "category" ])
return rec[ "category" ]
def make_response (category):
inner = json.dumps({ "category" : category, "confidence" : 0.9 })
return json.dumps({ "candidates" :[{ "content" :{ "parts" :[{ "text" : inner}]}}]})
random.seed( 20260830 )
sample = [random.choices(V20, weights = [ 22 , 18 , 14 , 12 , 10 , 10 , 9 , 5 ])[ 0 ] for _ in range ( 1000 )]
ok = enum_err = 0
for c in sample:
try :
client_v12_parse(make_response(c)); ok += 1
except ValueError :
enum_err += 1
print (ok, enum_err) # -> 849 151
1,000件のうち849件は通ります。壊れるのは151件、約15%です。
全部壊れるほうが、まだ扱いやすかったと思います。15%であれば、ユーザーからは「たまに分類されない」としか見えません。私の側では、リリース直後の変更が疑われます。実際に変わったのはサーバー側の読み替え設定で、アプリのコードは一行も動いていませんでした。
例外が出る場所に、モデル ID は現れません
もう一つ厄介なのは、例外が上がる場所です。同じコードを実際に落として、フレームを並べてみます。
import traceback, sys
try :
client_v12_parse(make_response( "japanese" ))
except Exception :
for i, f in enumerate (traceback.extract_tb(sys.exc_info()[ 2 ])):
print ( "frame %d : %s () line %d " % (i, f.name, f.lineno))
# frame0: <module>() line 24
# frame1: client_v12_parse() line 12
実際のアプリではこの間にビューモデルの生成が挟まりますから、報告される関数はさらに表示側へ寄ります。どの経路をたどっても、送信したモデル ID はスタックのどこにも現れません。
クラッシュの分類を自動化していても同じです。文字列としてのスタックトレースには、廃止されたモデルを指し続けていた事実が残っていないのですから。原因が API 側にあるとき、報告される場所は必ず遠くなります。
読み替えてよいかを決めるのは、モデルの系譜ではありません
gemini-omni-flash-preview は2026年9月30日で廃止されます。GA 版の gemini-omni-1.1-flash が後継として案内されており、これは公式のリリースノート に明記されています。GA 版で増えた解像度指定をどう扱うかは、4K を API 側で受け取るか配布直前に上げるかの判断 にまとめました。
ここで判断を誤りやすいのは、「後継が案内されている」ことと「黙って差し替えてよい」ことを同じ意味に取ってしまう点です。この2つは別の話です。
差し替えてよいのは、出力の契約が同じときだけです。返ってくる JSON の形、enum の値域、必須フィールドの有無。どれか一つでも広がっていれば、クライアントから見れば別の API です。実際に私が踏んだのは、まさにその「値域だけが広がった」ケースでした。
状況 取るべき振る舞い 理由
廃止前・契約が同じ そのまま通し、警告ヘッダだけ付ける まだ動く。急がせる必要はありません
廃止前・契約が違う 通すが、契約差を明示する 猶予のうちに気づける材料を渡します
廃止後・契約が同じ 後継へ差し替える クライアントから見て区別がつかないため安全です
廃止後・契約が違う 410 で断る 通すと、原因の見えない失敗に変わります
右下のマスだけが、今回の記事の主題です。ここを「親切に読み替える」と決めた瞬間に、冒頭のクラッシュが生まれます。
廃止台帳を、実行時に読める形で持つ
判断を人の記憶に置かないために、廃止情報をデータとして持ちます。日付だけでなく、後継が drop-in かどうかを人間が明示的に宣言する欄 を作るのが要点です。自動では決まりません。
from dataclasses import dataclass
from datetime import date, datetime, timezone
from typing import Optional
@dataclass ( frozen = True )
class SunsetEntry :
model_id: str
deprecated_on: date # 非推奨になった日
sunset_on: date # 実際に止まる日
successor: Optional[ str ]
successor_schema: int # 後継が返す体系のバージョン
successor_is_drop_in: bool # 出力契約が同じか(宣言)
LEDGER = {
"gemini-omni-flash-preview" : SunsetEntry(
"gemini-omni-flash-preview" , date( 2026 , 8 , 27 ), date( 2026 , 9 , 30 ),
"gemini-omni-1.1-flash" , successor_schema = 1 , successor_is_drop_in = True ),
"wallpaper-classifier-v1" : SunsetEntry(
"wallpaper-classifier-v1" , date( 2026 , 8 , 1 ), date( 2026 , 9 , 15 ),
"wallpaper-classifier-v2" , successor_schema = 2 , successor_is_drop_in = False ),
}
下の行は、個人開発で運用している壁紙アプリの分類器で実際に踏んだ形です。カテゴリ体系を自分で増やしたのですから、後継はあります。けれども drop-in ではありません。この False を書いておけば、廃止日のあとにゲートウェイが自動的に断ってくれます。
Deprecation と Sunset の書式を、私は最初に間違えました
ヘッダを付けるところで一度つまずきました。Deprecation: true と書いたのです。これは古いドラフトの形でした。
RFC 9745 では、Deprecation の値は structured field date、つまり @ に続く Unix 時刻です。一方の Sunset は RFC 8594 で定義されていて、こちらは HTTP-date 形式です。同じ目的の2つのヘッダで、日付の書き方が違います。
def _http_date (d: date) -> str : # RFC 8594 (Sunset)
return datetime(d.year, d.month, d.day, tzinfo = timezone.utc)\
.strftime( " %a , %d %b %Y %H:%M:%S GMT" )
def _sf_date (d: date) -> str : # RFC 9745 (Deprecation)
return "@ %d " % int (datetime(d.year, d.month, d.day,
tzinfo = timezone.utc).timestamp())
実際に出力させると、次のようになります。
Deprecation: @1787788800
Sunset: Wed, 30 Sep 2026 00:00:00 GMT
Link: <gemini-omni-1.1-flash>; rel="successor-version"
書式を間違えても、誰も怒りません。クライアントが黙って読み飛ばすだけです。設定が効かないのに何も起きない類の失敗で、この記事の主題と同じ構造をしています。
解決と拒否を、一つの関数に閉じ込める
台帳とヘッダが揃えば、判断そのものは短く書けます。
class Refusal ( Exception ):
def __init__ (self, payload): self .payload = payload
def resolve (model_id: str , client_schema: int , today: date):
"""戻り値: (実際に呼ぶモデルID, 付与するヘッダ)"""
e = LEDGER .get(model_id)
if e is None :
return model_id, {} # 台帳に無い = 現行
headers = { "Deprecation" : _sf_date(e.deprecated_on),
"Sunset" : _http_date(e.sunset_on)}
if e.successor:
headers[ "Link" ] = '< %s >; rel="successor-version"' % e.successor
drop_in = e.successor_is_drop_in and e.successor_schema == client_schema
if today < e.sunset_on:
return model_id, headers # 猶予期間。読み替えない
if drop_in:
return e.successor, headers # 契約が同じときだけ差し替える
raise Refusal({
"code" : 410 , "status" : "FAILED_PRECONDITION" , "reason" : "MODEL_SUNSET" ,
"requested" : model_id, "successor" : e.successor,
"sunset_date" : e.sunset_on.isoformat(),
"client_schema" : client_schema, "successor_schema" : e.successor_schema,
"action" : "UPDATE_CLIENT" ,
})
猶予期間中は読み替えないことにしています。ここで先回りして差し替えると、廃止日という節目が消えてしまうためです。動いているうちは動いたまま、警告だけを返す。線を引く日は一日だけにしておきます。
実行した結果です。
gemini-omni-flash-preview 2026-09-01 -> gemini-omni-flash-preview
gemini-omni-flash-preview 2026-10-01 -> gemini-omni-1.1-flash
wallpaper-classifier-v1 2026-09-01 -> wallpaper-classifier-v1
wallpaper-classifier-v1 2026-10-01 -> 410 MODEL_SUNSET (client v1 vs successor v2)
gemini-3.7-flash 2026-10-01 -> gemini-3.7-flash
410 のペイロードに client_schema と successor_schema を両方入れているのは、受け取った側が「なぜ断られたのか」を一目で判断できるようにするためです。UPDATE_CLIENT という文字列も、人ではなくコードが読む前提で置いています。
台帳そのものにも、検査を効かせる
台帳は手で書きます。手で書くものには、必ず矛盾が入ります。RFC 9745 は、Sunset の時刻が Deprecation より前になってはいけないと定めています。この制約はそのままテストにできます。
def validate_ledger (ledger) -> list :
bad = []
for e in ledger.values():
if e.sunset_on < e.deprecated_on:
bad.append( " %s : sunset %s が deprecation %s より前です"
% (e.model_id, e.sunset_on, e.deprecated_on))
if e.successor is None and e.successor_is_drop_in:
bad.append( " %s : 後継が無いのに drop-in を宣言しています" % e.model_id)
return bad
わざと壊した台帳を通すと、こう出ます。
['x: sunset 2026-09-01 が deprecation 2026-09-30 より前です',
'x: 後継が無いのに drop-in を宣言しています']
2つめの検査のほうを、私はよく踏みます。後継が決まっていない段階で「たぶん互換だろう」と書いてしまうためです。後継が無いのに drop-in であることはあり得ませんから、機械が止めてくれるほうが確実です。
締切は廃止日ではなく、残存が消える日から逆算します
ここまでの実装があっても、まだ足りません。断ると決めたなら、断られる端末を減らしておく必要があります。
配布済みのアプリは、こちらの都合では更新されません。段階公開で 5% から始めて 25%、50%、100% と引き上げても、100% になった瞬間に全員へ届くわけではありません。届く速さは端末の設定と使用頻度に依存します。到達を半減期でモデル化して、日数を出してみます。
STAGES = [( 0 , 0.05 ), ( 2 , 0.25 ), ( 4 , 0.50 ), ( 6 , 1.00 )] # (経過日, 配信率)
def reached (day, halflife):
r, prev = 0.0 , 0.0
for d, share in STAGES :
add, prev = share - prev, share
if day >= d:
r += add * ( 1 - 0.5 ** ((day - d) / halflife))
return min (r, 1.0 )
for hl in ( 3 , 7 , 14 ):
for target in ( 0.95 , 0.99 ):
day = 7
while reached(day, hl) < target and day < 4000 :
day += 1
print ( "半減期 %2d 日: 到達 %.0f%% まで %d 日" % (hl, target * 100 , day))
更新の半減期 95%へ到達 99%へ到達 30日後の残存
3日 18日 25日 0.3%
7日 35日 52日 8.1%
14日 65日 98日 28.3%
半減期を7日と見積もるなら、95% に届くまで35日かかります。9月30日の廃止に間に合わせたければ、対応版を出す締切は8月26日ということになります。私はこれを「マージした日」で数えていた時期があり、そのぶん毎回遅れていました。
半減期14日の側は、更新頻度の低い端末が多いアプリでの数字です。長く使われているアプリほど右の列に寄ります。3か月近く残る前提で計画を立てる、という話になります。
この逆算は、段階公開そのものを止める・進めるの判断とは別軸です。日々の引き上げ判断については段階公開を止める判断の切り分け に書きました。モデル側の追従をどう設計するかはエイリアス追従をやめてピン留めとカナリアに移した記録 が近い話です。
断られた側に、何を見せるか
410 を返すところまで作っても、クライアントが握り潰せば同じことです。受け取り側には、最低限これだけを実装しておきます。
reason が MODEL_SUNSET なら、リトライしない。指数バックオフの対象から外します
機能を無効化して、更新を促す表示に切り替える。エラーダイアログではなく、その機能だけを畳みます
sunset_date と successor をログに残す。次に同じことが起きたとき、台帳のどの行かがすぐ分かります
2番目が実務では効きます。分類が止まっただけでアプリ全体が使えなくなるのは、ユーザーから見て過剰です。私が運用しているアプリでは、分類結果は表示の補助でしかありませんから、その列を隠して他は通常どおり動かします。
止め方を先に決めておくと、断る判断が怖くなくなります。逆に言えば、縮退の道筋がないうちは読み替えたくなるのが自然です。冒頭の私がそうでした。
まず、台帳に1行書くところから
gemini-omni-flash-preview の廃止まで、この記事を書いている時点で1か月ほどあります。自分のコードがプレビュー版のエンドポイントを指しているかどうかを確かめて、指していたら台帳に1行足してみてください。successor_is_drop_in を True にするか False にするかを決める、その一回の判断が、この設計のほとんどすべてです。
出力の契約が同じなら黙って差し替えてよい。違うなら断る。モデルの世代が上がるたびにこの問いへ戻れるように、判断をコードの側へ置いておきたいと私は考えています。