CourseAI System Design: The Complete Course · Module 1: How AI Systems Differ · part 12 of 107
Part 12 · Module 1: How AI Systems Differ

Incident: A provider change broke a quarter of requests

11 min read·
Module 1 · Incident file · About 30 minutes

What happened

TIMELINE 49 minutes of failures 29 SEPTEMBER 2025, UTC 24% of Gemini requests fail 17:53 start 18:39 first post 18:42 end 19:12 report fixed by the provider's rollback
Gemini requests in Copilot failed for 49 minutes (red); the first public status post came at 18:39, three minutes before the provider's rollback ended it.

On 29 September 2025, between 17:53 and 18:42 UTC, GitHub Copilot "experienced a degradation of the Gemini 2.5 model due to an issue with our upstream provider". GitHub's incident report on its status page gives the size: "Approximately 24% of requests failed, affecting 56% of users during this period. No other models were impacted."

GitHub deployed nothing. The report says "GitHub notified the upstream provider of the problem as soon as it was detected", and "the issue was resolved after the upstream provider rolled back a recent change that caused the disruption". Its one stated follow-up: "GitHub will continue to enhance our monitoring and alerting systems to reduce the time it takes to detect and mitigate similar issues in the future."

The status page's own timestamps add one fact. Its first post, "We are currently investigating this issue", is timed 18:39 UTC, 46 minutes after the start the summary gives and 3 minutes before the end. The next post said: "Other models are available and working as expected."

COMPARE Two incidents, two fixes 29 Sep: Gemini 24% of requests failed 49 minutes provider rolled back 23 to 24 Sep: Claude 0.46%, 7.83% of requests failed 8 h 52 min routed to other providers
One fix came from the provider (amber, out of GitHub's hands); the other from GitHub's own routing to other providers (blue), the response Module 7 builds.

A week earlier, from 20:06 UTC on 23 September to 04:58 UTC on 24 September 2025, a different upstream provider had problems with two Claude models in Copilot. That time GitHub's report describes the response the course builds in Module 7: "Copilot disabling routing to an upstream provider that was experiencing issues and reallocating capacity to other providers". In that window 0.46% of Claude Sonnet 4 requests and 7.83% of Claude Sonnet 3.7 requests failed.

Why it happened

HOW IT WORKS A change you never deployed provider change deployed upstream same model name in your config requests fail for your users your deploy log: no entry
The change travelled from the provider (amber) through an unchanged model name to failing requests (red), and the product's own deploy log (grey) shows nothing.

The cause sat outside GitHub's deploy log. A recent change on the provider's side reached Copilot's users through the model name in GitHub's configuration, with no change on GitHub's side. That is Lesson 1's fifth structural difference, behaviour changing without a deploy, and its seventh: a dependency you cannot debug. GitHub's only levers were to tell the provider and wait for the rollback, or to send traffic elsewhere.

BY THE NUMBERS 24% of requests, 56% of users USERS WHO SEE A FAILURE, IF 24% OF REQUESTS FAIL 1 request 24% 2 requests 42% 3 requests 56% 4 requests 67% 1 - 0.76^k, independent failures (illustrative)
As arithmetic only, a user sending three requests at a 24% failure rate has a 56% chance of seeing a failure, the share GitHub reported (red).

The failure was partial, and that is what makes it slow to see. Three requests in four still worked. A health check that sends one request has a 76% chance of passing. Users felt it far more than the request rate suggests: 24% of requests, but 56% of users. One way to read that, as arithmetic only: if each user sent about three requests and each failed independently, the chance of seeing at least one failure is 1 - 0.76 × 0.76 × 0.76 = 0.56.

COMPARE Three signals, one is yours OUTSIDE YOUR BOUNDARY provider status page what the provider noticed user reports late, partial your call log every call, per model alert over 5% of 50
Only the call log at your own boundary (blue) sees every call per model and can drive an alert (green); the provider's page (grey) shows what the provider noticed.

So detection has to come from your own side, at your own boundary, per model. A provider's status page tells you what the provider has noticed. Your call log tells you what your users got. Lesson 2 quoted GitHub's David Cheney on the same point: from the upstream provider's metrics, "we barely get averages".

The same failure at Northdesk

Replay it on Northdesk's Ask path. Tallis staff ask Quinn's question from Lesson 1 through the call log. After 300 requests, a simulated change on the primary provider's side breaks 24% of new requests, GitHub's rate. Nothing at Northdesk changes: same model name, same prompt version. The replay uses the Module 1 lab's repository, so paste the lab's first cells. On a fresh session they write what is missing; after the lab they print kept.

python
import os
import sys

if os.path.basename(os.getcwd()) != "northdesk-platform":   # not in the repository yet
    if "google.colab" in sys.modules:                         # in Colab: the repository lives in your Drive
        from google.colab import drive
        drive.mount("/content/drive")
        os.chdir("/content/drive/MyDrive")
    os.makedirs("northdesk-platform", exist_ok=True)
    os.chdir("northdesk-platform")
print("working in", os.path.basename(os.getcwd()))

Code walkthrough: the lab's Step 1 cell, unchanged: move into northdesk-platform, in your Drive in Colab.

python
from pathlib import Path
from IPython.core.magic import register_cell_magic

WROTE = set()                              # files this session's %%ensure wrote

@register_cell_magic
def ensure(line, cell):
    """%%ensure path: save the cell unless the file exists. %%ensure -a path: add to a file this session wrote."""
    *flags, name = line.split()
    path, text = Path(name), cell.rstrip("\n") + "\n"
    if flags == ["-a"]:
        if name in WROTE:
            with path.open("a", encoding="utf-8") as f:
                f.write("\n\n" + text)
            print("added to", path)
        return
    if path.exists():
        WROTE.discard(name)                # a kept file gets no added parts either
        print("kept", path)
        return
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")
    WROTE.add(name)
    print("wrote", path)

Code walkthrough: the lab's %%ensure command, unchanged: write a file only when it is missing.

python
%%ensure northdesk/__init__.py
"""Northdesk platform: the design, and the code that proves it (course: AI System Design)."""

Code walkthrough: the package's __init__.py, as the lab wrote it.

python
%%ensure northdesk/trace.py
"""Northdesk call log, version 1: one JSON line per model call. Module 9 turns it into span trees."""
import json
import os
import time
import uuid
from datetime import datetime, timezone

LOG_PATH = "logs/calls.jsonl"


def logged_ask(ask, model, messages, *, feature, tenant_id, model_name, prompt_version, path=LOG_PATH):
    """Call the model through ask() and log the call, whether it succeeded or not."""
    start = time.perf_counter()
    reply, err = ask(model, messages)
    usage = (getattr(reply, "usage_metadata", None) or {}) if reply is not None else {}
    record = {"call_id": uuid.uuid4().hex[:12], "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
              "feature": feature, "tenant_id": tenant_id, "model": model_name, "prompt_version": prompt_version,
              "input": [list(m) for m in messages], "output": None if err else reply.text,
              "input_tokens": usage.get("input_tokens"), "output_tokens": usage.get("output_tokens"),
              "latency_ms": round((time.perf_counter() - start) * 1000), "status": err or "ok"}
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False) + "\n")
    return reply, err


def read_calls(path=LOG_PATH):
    """Every logged call, oldest first."""
    with open(path, encoding="utf-8") as f:
        return [json.loads(line) for line in f]

Code walkthrough: northdesk/trace.py version 1, as Lesson 3 and the lab wrote it: one JSON line per call, failures included.

python
def ask(model, messages):
    """Returns (reply, None), or (None, reason) after two refusals."""
    for attempt in range(2):                     # one retry: the next reply is usually fine
        try:
            return model.invoke(messages), None
        except Exception as err:
            last = err
    return None, f"provider_error: {type(last).__name__}"

Code walkthrough: course-guide/02's guard: two tries, then provider_error instead of a crash.

Two scripted stand-ins play the providers. Neither is a model, and nothing they print is model output.

python
import os
import random
from collections import Counter
from types import SimpleNamespace
from northdesk.trace import logged_ask, read_calls

ANSWER = SimpleNamespace(text="You get 30 days of annual leave a year. [t-tallis-d002]",
                         usage_metadata={"input_tokens": 2958, "output_tokens": 363})


class Primary:
    """Scripted stand-in for the primary provider (simulated). After `changed_at` requests, a change on the
    provider's side breaks `share` of new requests; a retry of a broken request fails the same way."""
    def __init__(self, changed_at, share=0.24, seed=20261005):
        self.changed_at, self.share, self.rng = changed_at, share, random.Random(seed)
        self.requests, self.last, self.broken = 0, None, False

    def invoke(self, messages):
        if messages is not self.last:          # a new request, not ask()'s retry of the same one
            self.last, self.requests = messages, self.requests + 1
            self.broken = self.requests > self.changed_at and self.rng.random() < self.share
        if self.broken:
            raise ConnectionError("upstream error")
        return ANSWER


class Secondary:
    """Scripted stand-in for the secondary provider: it always answers (simulated)."""
    def invoke(self, messages):
        return ANSWER

Code walkthrough

  • ANSWER is a fixed reply with the token counts of an average v8 call in the shared traces (2,958 in, 363 out).
  • Primary counts requests. messages is not self.last tells a new request from ask()'s retry of the same one; a broken request stays broken, so the retry cannot hide it.
  • After changed_at requests, each new request breaks with probability share, from a seeded generator, so every run is the same.
python
def replay(answer, log, n=600):
    """n Ask requests from Tallis staff, each a new message list, answered by `answer(messages, log)`."""
    if os.path.exists(log):
        os.remove(log)
    return [answer([("user", "How many days of annual leave do I get?")], log) for _ in range(n)]


primary = Primary(changed_at=300)
direct = lambda messages, log: logged_ask(ask, primary, messages, feature="ask", tenant_id="t-tallis",
                                          model_name="primary", prompt_version="v8", path=log)
results = replay(direct, "logs/incident-before.jsonl")
calls = read_calls("logs/incident-before.jsonl")
failed = [i for i, (reply, err) in enumerate(results) if err]
print(len(failed), "of", len(results), "requests failed; the first at request", failed[0] + 1)
print(f"after the change: {sum(i >= 300 for i in failed) / 300:.1%} failed | before it: {sum(i < 300 for i in failed)}")
print("what changed at Northdesk:", {c["model"] for c in calls}, {c["prompt_version"] for c in calls})

Code walkthrough

  • replay deletes the old log, then sends n requests, each a new list, so Primary sees a new request every time.
  • direct is Northdesk's Ask call today: one provider, through logged_ask.
  • The prints count failed requests, the share after the change, and every model name and prompt version in the log.

Run this and you will see:

text
79 of 600 requests failed; the first at request 303
after the change: 26.3% failed | before it: 0
what changed at Northdesk: {'primary'} {'v8'}

Tallis staff got provider_error on about a quarter of their questions, and the log names one model and one prompt version throughout: from Northdesk's side, nothing changed. Every failure is in the log, but nothing reads it.

The fix

BY THE NUMBERS Failures per 50 requests NORTHDESK REPLAY, SIMULATED 0 0 0 0 0 0 16 12 9 17 11 14 provider change after request 300 request 1 request 600
With nothing changed at Northdesk, failures jump from none (green) to 9 to 17 in every 50 requests (red) once the simulated provider change lands.

Two changes, both small. First, read the call log the way an alert would: over the last 50 calls to each model, alert when more than 5% failed, Lesson 2's threshold.

python
def first_alert(calls, model, window=50, limit=0.05):
    """How many of `model`'s calls had been made when over `limit` of its last `window` failed; None if never."""
    recent, seen = [], 0
    for c in calls:
        if c["model"] != model:
            continue
        seen += 1
        recent = (recent + [c["status"].startswith("provider_error")])[-window:]
        if len(recent) == window and sum(recent) / window > limit:
            return seen
    return None


alert = first_alert(calls, "primary")
print("alert at primary call", alert, "=", alert - 300, "requests after the change")
print(f"at the shared traffic's busiest five minutes (2.40 requests a second): {(alert - 300) / 2.40:.0f} s")

Code walkthrough

  • recent keeps the last window outcomes for one model (True for a provider_error); [-window:] drops the oldest.
  • The alert needs a full window, so one early failure cannot fire it, and it is per model, so a healthy model cannot hide a broken one.
  • 2.40 requests a second is the busiest five minutes in course-guide/06's traffic; the division turns requests into seconds (simulated).

You should see

text
alert at primary call 306 = 6 requests after the change
at the shared traffic's busiest five minutes (2.40 requests a second): 2 s

The rule fires 6 requests after the change: about 2 seconds at that rate (simulated). It needs nobody to notice and nothing from the provider.

Second, give the request somewhere else to go. GitHub's report on 23 September shows the pattern: stop routing to the failing provider, use the others. The smallest version is a chain: try the primary, and on provider_error try the secondary, both through the call log. (Module 7 builds the real gateway, with budgets and a circuit breaker that stops trying the primary at all.)

python
def ask_with_fallback(messages, log, chain):
    """Try each (name, model) in turn through the call log; return the first answer, or the last error."""
    for name, model in chain:
        reply, err = logged_ask(ask, model, messages, feature="ask", tenant_id="t-tallis",
                                model_name=name, prompt_version="v8", path=log)
        if err is None:
            return reply, None
    return None, err


chain = [("primary", Primary(changed_at=300)), ("secondary", Secondary())]
fixed = replay(lambda messages, log: ask_with_fallback(messages, log, chain), "logs/incident-after.jsonl")
after = read_calls("logs/incident-after.jsonl")
print(sum(err is not None for _, err in fixed), "of", len(fixed), "requests failed")
print("calls by model:", dict(Counter(c["model"] for c in after)),
      "| alert at primary call", first_alert(after, "primary"))

Code walkthrough

  • chain is an ordered list of providers; the loop stops at the first answer.
  • Each attempt goes through logged_ask, so the primary's failures stay in the log even when the user gets an answer.
  • The same seed makes the primary fail on the same requests as before, so the two runs compare like for like.

You should see

text
0 of 600 requests failed
calls by model: {'primary': 600, 'secondary': 79} | alert at primary call 306
BEFORE AND AFTER A fallback, the same alert 79 failed, one provider 0 failed, with fallback 306 alert at primary call 600 Tallis requests; the alert fires at call 306 in both runs; the secondary answered 79 requests
The fallback turns 79 failed requests (red) into none (green), while the call log still raises the alert at the same call (blue).

No Tallis request failed, the secondary answered the ones the primary broke, and the alert fired at the same call as before, because the fallback hides the failure from users but not from the log.

How you would have caught it

HOW IT WORKS The incident as a test 1 100 requests no alert 2 change 24% break 3 alert within 50 4 users 0 failed Module 11: tests/test_faults.py, provider row
The test replays the incident without a key and fails the build if the alert is late (blue) or a user sees an error.

A test makes the design hold: replay a provider change with scripted stand-ins and assert that the alert fires within one window and that no request fails. It costs nothing to run, needs no key, and belongs in Module 11's tests/test_faults.py beside the provider row of docs/failure-modes.md.

python
def test_provider_change_is_caught_and_contained():
    chain = [("primary", Primary(changed_at=100)), ("secondary", Secondary())]
    results = replay(lambda messages, log: ask_with_fallback(messages, log, chain), "logs/incident-test.jsonl", n=300)
    calls = read_calls("logs/incident-test.jsonl")
    assert first_alert(calls[:100], "primary") is None              # quiet before the change
    alert = first_alert(calls, "primary")
    assert alert is not None and alert - 100 <= 50, alert           # caught within one window
    assert all(err is None for _, err in results)                   # and no user saw it


test_provider_change_is_caught_and_contained()
print("passed")

Code walkthrough

  • The change comes after 100 requests; calls[:100] are the calls before it, which must not alert.
  • alert - 100 <= 50 says the change is caught within one 50-call window; the last line says the chain contained it.

You should see

text
passed

Key points

  • A provider can break your product with no deploy on your side: GitHub's 29 September 2025 report blames "a recent change" upstream, fixed by the provider's rollback.
  • Partial failures hide: 24% of requests failed, yet three requests in four worked, so one-request health checks mostly pass.
  • Measure at your own boundary, per model: Northdesk's call log already holds every failure; an alert over a 50-call window turns it into a signal.
  • Keep somewhere else to go: a second provider kept every replayed Tallis request answered, and the failures stayed visible in the log.
  • Make it a test with scripted stand-ins, so the design is checked on every change, not on the day of the incident.

Sources

  • GitHub, "Disruption with Gemini 2.5 Pro and Gemini 2.0 Flash in Copilot", GitHub Status incident report, 29 September 2025, read 18 October 2026. https://www.githubstatus.com/incidents/0s5rb1l03m76
  • GitHub, "Incident with Copilot", GitHub Status incident report, 23 to 24 September 2025, read 18 October 2026. https://www.githubstatus.com/incidents/cm1pvx7g44d5
  • David Cheney (GitHub), "How GitHub Copilot Serves 400 Million Completion Requests a Day", QCon San Francisco, InfoQ recording dated 24 March 2025 (quoted in Lesson 2). https://www.infoq.com/presentations/github-copilot/