Incident: A provider change broke a quarter of requests
Module 1 · Incident file · About 30 minutes
What happened
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."
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
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.
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.
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.
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.
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.
%%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.
%%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.
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.
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 ANSWERCode walkthrough
ANSWERis a fixed reply with the token counts of an average v8 call in the shared traces (2,958 in, 363 out).Primarycounts requests.messages is not self.lasttells a new request fromask()'s retry of the same one; a broken request stays broken, so the retry cannot hide it.- After
changed_atrequests, each new request breaks with probabilityshare, from a seeded generator, so every run is the same.
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
replaydeletes the old log, then sendsnrequests, each a new list, soPrimarysees a new request every time.directis Northdesk's Ask call today: one provider, throughlogged_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:
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
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.
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
recentkeeps the lastwindowoutcomes for one model (Truefor aprovider_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
alert at primary call 306 = 6 requests after the change
at the shared traffic's busiest five minutes (2.40 requests a second): 2 sThe 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.)
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
chainis 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
0 of 600 requests failed
calls by model: {'primary': 600, 'secondary': 79} | alert at primary call 306No 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
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.
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 <= 50says the change is caught within one 50-call window; the last line says the chain contained it.
You should see
passedKey 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/