Flaky tests in CI are a tax on engineering velocity, and LLM API integrations make them worse because the network, rate limits, and model nondeterminism all conspire to break builds randomly. This guide lays out concrete ci strategies flaky llm tests that we use to keep pipelines deterministic without losing real-world coverage of our integration code. You will get an ordered path from fixture isolation to live-call tolerance, with code you can drop into a pytest or GitHub Actions setup today.
1. Isolate network calls with recorded fixtures
The first move in any serious ci strategies flaky llm tests is to never hit the real API from unit tests. Record a single successful exchange once, store it as a fixture, and replay it on every run. Tools like VCR.py or a hand-rolled monkeypatch work; the key is that the test asserts your code’s logic, not the provider’s uptime.
import pytest
from openai import OpenAI
def test_prompt_template_renders(monkeypatch):
def fake_create(*args, **kwargs):
# assert our code sent the right shape
assert kwargs["model"].startswith("gpt")
return type("R", (), {"choices": [type("C", (), {"message": type("M", (), {"content": "42"})()})()]})()
monkeypatch.setattr("openai.resources.chat.completions.Completions.create", fake_create)
client = OpenAI()
out = client.chat.completions.create(model="gpt-4o", messages=[{"role":"user","content":"what is 6*7?"}])
assert out.choices[0].message.content == "42"
A fixture file makes the recorded exchange reviewable:
{
"request": {
"model": "gpt-4o",
"messages": [{"role": "user", "content": "what is 6*7?"}]
},
"response": {
"choices": [{"message": {"content": "42"}}]
}
}
Pitfall: fixtures rot. A recorded response from last quarter may not match the current API schema. Treat fixtures like code—review them in PRs and regenerate on purpose, not by accident.
2. Contract-test the request, not the response
You care that your client sends the correct model, headers, and body. Stand up a local mock server and assert on the incoming request. This catches breaking changes in your own wrapper before they reach production.
import responses
import openai
@responses.activate
def test_request_contract():
responses.add(responses.POST, "https://api.openai.com/v1/chat/completions",
json={"choices":[{"message":{"content":"pong"}}]}, status=200)
client = openai.OpenAI()
client.chat.completions.create(model="gpt-4o", messages=[{"role":"user","content":"ping"}])
req = responses.calls[0].request
assert "Authorization" in req.headers
assert "ping" in req.body.decode()
Tradeoff: mocks can drift from real provider behavior. Run these fast tests on every commit, but pair them with the live lane from step 4 so you still learn when the real API changes.
3. Split CI into fast and live lanes
A single pytest marker separates the deterministic suite from the calls that need a live model. The fast lane must be zero-tolerance; the live lane can be allowed to fail without redoing the whole pipeline.
# fast lane: no network, runs in <30s
pytest -m "not live" --junitxml=fast.xml
# live lane: tolerated flake, runs nightly or on demand
pytest -m "live" --junitxml=live.xml || echo "live lane flaked; check dashboard"
This split is the backbone of ci strategies flaky llm tests because it lets developers get immediate feedback on logic while still exercising the real integration on a schedule. Common mistake: marking too many tests as live. If a test does not need a model’s creativity, it belongs in the fast lane.
4. Route live tests through a fallback-aware gateway
When you do run live calls, provider 429s and transient 5xx errors are the dominant source of flake. Pointing the test client at an OpenAI-compatible gateway that automatically falls back when a provider is rate-limited or degraded removes most of that noise.
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.n4n.ai/v1", # OpenAI-compatible, 240+ models, auto fallback
api_key=os.environ["N4N_KEY"],
)
# same code as before, but a degraded primary provider won't fail the call
resp = client.chat.completions.create(model="anthropic/claude-3.5-sonnet", messages=[{"role":"user","content":"hi"}])
The gateway honors your routing directives and forwards provider cache-control hints, so you can still test cache hits. This is not a substitute for mocking—it is a way to make the unavoidable live tests less random.
5. Force determinism where the API allows
Set temperature=0 and fixed seed where the provider supports it. Snapshot the response into a golden file and diff on subsequent runs. Accept that some drift is inevitable; use a similarity threshold rather than exact match for free-form text.
def test_golden_output():
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role":"user","content":"define: latency"}],
temperature=0,
seed=1234,
)
text = resp.choices[0].message.content
golden = open("tests/golden/latency.txt").read()
assert text.strip()[:50] == golden.strip()[:50] # prefix match tolerates minor rewording
Pitfall: golden tests become a maintenance burden when models update. Review them quarterly, and never block a release on a one-word synonym change.
6. Measure flake rate and quarantine
The final piece of ci strategies flaky llm tests is measurement. You cannot improve what you do not measure. Parse the JUnit XML from the live lane and compute the flake rate per test over the last 50 runs. If a test fails intermittently more than 2% of the time, quarantine it behind a marker and file a ticket.
import xml.etree.ElementTree as ET
def flake_rate(xml_path):
tree = ET.parse(xml_path)
root = tree.getroot()
tests = root.findall(".//testcase")
flaky = [t for t in tests if t.find("failure") is not None or t.find("error") is not None]
return len(flaky) / max(1, len(tests))
# run nightly, alert if rate > 0.02
Quarantining is not hiding the problem; it protects the signal. The remaining green build means “your code is correct,” not “the provider happened to be awake.”
7. Cache control and cost guards
Live lanes burn tokens. Use provider cache-control hints to avoid recomputing the same prompt, and meter usage per token so a test loop cannot silently drain the budget. In CI, set a hard cap via environment variable and fail the live lane if exceeded.
export OPENAI_MAX_TOKENS=50000 # per CI job
pytest -m live || echo "check token cap"
Tradeoff: caching can mask differences between cached and uncached paths. Toggle cache headers off in one nightly run to confirm both branches work.
Common pitfalls to avoid
- Treating mock responses as equivalent to real model behavior. They are not; they verify your code, not the LLM.
- Allowing live tests to block merge. That turns flakiness into a productivity fire.
- Ignoring fixture drift. A stale recorded response gives false confidence.
- Over-seeding golden tests. If you assert exact text, you will spend more time updating fixtures than writing features.
The ordered path is: isolate with fixtures, contract-test requests, split lanes, route live through fallback, enforce determinism, measure flake, guard cost. Follow it and your ci strategies flaky llm tests will keep the pipeline honest without driving engineers to disable the tests entirely.