●SHUTDOWN — gemini-robotics-er-1.6-preview retires today, August 31. Any code still pointing at that preview model stops working from here on●DEPRECATION — September 30 is the next date to watch: the gemini-omni-flash-preview endpoint goes away and needs swapping for gemini-omni-1.1-flash, which reached GA on August 27●VIDEO — The Omni 1.1 Flash GA adds video extension through the extend task, and interpolation by passing two images to image_to_video so you can fix the first and last frame up front●VIDEO — A resolution parameter in video_config now accepts 360p, 720p as the default, 1080p, and 4k, with the note that 1080p and 4K outputs are produced by upscaling●SPEECH — gemini-3.5-transcribe reached GA on August 26 with utterance-level language detection across 85+ languages, speaker diarization, word-level timestamps, and up to 1,000 custom vocabulary terms●SPEECH — gemini-3.5-transcribe-live streams both ways over WebSockets on the Live API, with interim and finalized transcription events, a Smart transcription mode, and several VAD settings●SHUTDOWN — gemini-robotics-er-1.6-preview retires today, August 31. Any code still pointing at that preview model stops working from here on●DEPRECATION — September 30 is the next date to watch: the gemini-omni-flash-preview endpoint goes away and needs swapping for gemini-omni-1.1-flash, which reached GA on August 27●VIDEO — The Omni 1.1 Flash GA adds video extension through the extend task, and interpolation by passing two images to image_to_video so you can fix the first and last frame up front●VIDEO — A resolution parameter in video_config now accepts 360p, 720p as the default, 1080p, and 4k, with the note that 1080p and 4K outputs are produced by upscaling●SPEECH — gemini-3.5-transcribe reached GA on August 26 with utterance-level language detection across 85+ languages, speaker diarization, word-level timestamps, and up to 1,000 custom vocabulary terms●SPEECH — gemini-3.5-transcribe-live streams both ways over WebSockets on the Live API, with interim and finalized transcription events, a Smart transcription mode, and several VAD settings
Why Shipped Clients Deserve a Refusal, Not a Silent Model Substitution
A model can retire, but the apps already on people's phones cannot. This is how I built a sunset ledger keyed on output contracts, and how I now back-date my own deadline from the version residue curve.
The crash report pointed at a fifteen-line function that turns a category name into a screen label. Not the network layer. Not the response decoder. The very last hop before rendering.
The cause was nowhere near that function. Our gateway had been quietly rewriting a retired model ID to its successor, and the successor had grown two extra values in its category enum.
Rewriting felt like the kind thing to do. Better to keep serving traffic than to hand a 410 to an app someone already installed. And it did keep serving traffic — that is exactly why nobody noticed.
"Mostly working" is the hardest state to detect
The shipped client recognized six categories. The successor returned eight. The two new ones show up in only a slice of real traffic. Here is the distribution, reproduced locally.
import json, randomV12 = {"nature","abstract","city","animal","art","minimal"} # what the shipped app knowsV20 = list(V12) + ["japanese","texture"] # what the successor returnsdef client_v12_parse(body): obj = json.loads(body) # layer 1: transport + JSON text = obj["candidates"][0]["content"]["parts"][0]["text"] rec = json.loads(text) # layer 2: structured output if rec["category"] not in V12: # layer 3: enum -> view model 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 = 0for c in sample: try: client_v12_parse(make_response(c)); ok += 1 except ValueError: enum_err += 1print(ok, enum_err) # -> 849 151
849 of 1,000 succeed. Roughly 15% fail.
A total outage would have been easier to handle. At 15%, users experience it as "sometimes it just doesn't categorize." On my side, suspicion falls on whatever shipped most recently. What actually changed was a server-side rewrite rule, and not one line of app code had moved.
The model ID never appears where the exception does
The second problem is where the failure surfaces. Drop the same code and look at the frames.
import traceback, systry: 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
In a real app there is a view-model builder in between, so the reported frame sits even closer to the UI. Along every path, the model ID you sent is absent from the stack.
Automated crash clustering does not rescue you here. The stack trace, as a string, carries no trace of the fact that a retired model was still being requested. When the cause sits on the API side, the reported location is always somewhere far away.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to decide when a successor model may be swapped in silently based on the output contract rather than the model lineage, so you never inherit a failure whose cause is invisible after the sunset date
✦You will be able to back-date your own shipping deadline from a published sunset date, accounting for how slowly updates actually reach installed apps
✦You will be able to explain why silent substitution stays hidden, using a measurement where 849 of 1,000 responses pass through unharmed
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The trap is treating "a successor exists" and "you may swap silently" as the same statement. They are not.
A silent swap is safe only when the output contract is identical: the JSON shape, the enum domain, the set of required fields. Widen any one of them and, from the client's point of view, you have pointed it at a different API. The case that bit me was precisely the narrow one — only the enum domain grew.
Situation
Correct behavior
Why
Before sunset, contract identical
Pass through, attach warning headers
It still works. No need to rush anyone
Before sunset, contract differs
Pass through, but surface the contract gap
Give clients something to notice during the grace period
After sunset, contract identical
Swap to the successor
Indistinguishable from the client's side
After sunset, contract differs
Refuse with 410
Passing it through converts a clear failure into an invisible one
Only that bottom-right cell matters here. Choose "be kind and rewrite" there, and you have manufactured the crash from the opening paragraph.
Make the sunset ledger something code can read
Keep the retirement facts in data rather than in someone's memory. The key column is not the date — it is an explicit human declaration of whether the successor is drop-in. Nothing infers that for you.
from dataclasses import dataclassfrom datetime import date, datetime, timezonefrom typing import Optional@dataclass(frozen=True)class SunsetEntry: model_id: str deprecated_on: date # deprecation begins sunset_on: date # the day it actually stops successor: Optional[str] successor_schema: int # version of the contract the successor returns successor_is_drop_in: bool # declared by a human, not inferredLEDGER = { "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),}
The second entry is the one I actually hit, on a classifier I run for my own wallpaper apps. A successor exists, because I wrote it. It is not drop-in, because I widened the taxonomy. Recording that False is what makes the gateway refuse on its own once the date passes.
I got the Deprecation and Sunset formats wrong first
My first attempt emitted Deprecation: true. That is the old draft spelling.
Under RFC 9745, the Deprecation value is a structured field date — an @ followed by Unix time. Sunset, defined in RFC 8594, uses HTTP-date instead. Two headers for one lifecycle, two different date formats.
Getting the format wrong produces no complaint. Clients simply skip what they cannot parse. It is the same shape of failure this whole article is about: a setting that stops working without making a sound.
Put resolution and refusal in one function
With the ledger and the headers in place, the decision itself is short.
class Refusal(Exception): def __init__(self, payload): self.payload = payloaddef resolve(model_id: str, client_schema: int, today: date): """Returns (model to actually call, headers to attach).""" e = LEDGER.get(model_id) if e is None: return model_id, {} # not in the ledger = current 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 # grace period: never rewrite if drop_in: return e.successor, headers # identical contract only 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", })
Nothing gets rewritten during the grace period. Swapping early erases the one moment that carries information — the sunset date itself. Let it keep working while it works, warn, and draw the line on exactly one day.
Both client_schema and successor_schema ride along in the 410 payload so the receiver can see at a glance why it was turned away. UPDATE_CLIENT is there for code to branch on, not for a human to read.
Test the ledger itself
Ledgers are hand-written, and hand-written things contradict themselves. RFC 9745 states that the sunset timestamp must not be earlier than the deprecation timestamp. That constraint converts directly into a test.
def validate_ledger(ledger) -> list: bad = [] for e in ledger.values(): if e.sunset_on < e.deprecated_on: bad.append("%s: sunset %s precedes 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: declared drop-in with no successor" % e.model_id) return bad
Feed it a deliberately broken entry:
['x: sunset 2026-09-01 precedes deprecation 2026-09-30',
'x: declared drop-in with no successor']
The second check catches me more often. Before a successor is settled, it is tempting to write "probably compatible." A model with no successor cannot be drop-in, so let the machine hold that line.
Your deadline is the residue curve, not the sunset date
None of this helps if the refusals land on a large population. Once you decide to refuse, you owe it to yourself to shrink the group that gets refused.
Installed apps do not update on your schedule. A staged rollout that climbs 5% to 25% to 50% to 100% does not reach everyone the moment it hits 100%. Delivery speed depends on device settings and how often the app is opened. Model the arrival as a half-life and count the days.
STAGES = [(0, 0.05), (2, 0.25), (4, 0.50), (6, 1.00)] # (day, rollout share)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("half-life %2dd: %.0f%% reached after %d days" % (hl, target * 100, day))
Update half-life
To 95%
To 99%
Residue at day 30
3 days
18 days
25 days
0.3%
7 days
35 days
52 days
8.1%
14 days
65 days
98 days
28.3%
At a seven-day half-life, 95% takes 35 days. To meet a September 30 sunset, the fixed build has to ship by August 26. As an indie developer I used to count from the day I merged the change, and I was late every single time.
The fourteen-day row describes apps with a lot of infrequent users. The longer an app has been in the store, the further right it sits. Plan on carrying that tail for close to three months.
A 410 accomplishes nothing if the client swallows it. Three behaviors are the minimum on the receiving side.
When reason is MODEL_SUNSET, do not retry. Exclude it from exponential backoff entirely
Disable the feature and switch to an update prompt. Fold away that one capability rather than throwing a dialog
Log sunset_date and successor. Next time, you will know which ledger row fired
The second one carries the most weight in practice. Losing categorization should not cost the user the whole app. In my apps, classification only decorates the display, so I hide that column and let everything else run normally.
Deciding the degradation path first is what makes refusing feel safe. Put another way: as long as there is no graceful path, rewriting will keep looking attractive. That is where I was at the start.
Start with a single ledger row
There is about a month left before gemini-omni-flash-preview retires. Check whether your code still points at a preview endpoint, and if it does, add one row to the ledger. Deciding whether successor_is_drop_in is True or False is very nearly the whole design.
Identical contract, swap silently. Different contract, refuse. I want that question to live in code, so that every new model generation walks straight back into it.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.