A green test suite does not mean a service can fail safely. If an AI-generated PR ships outbound calls without timeouts, you inherit hanging workers, retry storms, and duplicate side effects. Merge only when failure contracts are explicit and the evidence sits in the PR.
Happy-path tests are a weak merge signal. You need gates for deadlines, bounded retries, idempotency, and poison payloads. The checklist below is copy-pasteable. The scanner is fail-closed: if it cannot run, the build is red.
This is a filter, not an architecture review and not a load test. Remove every product name from this article and the gates still work.
Why AI PRs skip failure contracts
AI coding tools optimize for “it worked on my sample request.” They will write requests.get(url) with no timeout. They will retry a POST. They will swallow Exception and return 200.
Your CI can still be green. The damage shows up under partial outages, not in fixtures. Treat every AI-assisted change as untrusted until the failure mode is named, tested, and bounded.
If the PR cannot show evidence, you do not merge. Missing evidence is a no, not a discussion.
Eight gates, required evidence, fail-closed criteria
Paste this into the PR template. Each gate needs an artifact. “We’ll add it later” is a failed gate.
1. Explicit deadlines on every outbound call
- Gate: Every HTTP, RPC, and database call sets a timeout or deadline in code.
- Evidence: A file:line list of clients, plus a test that constructs the client with a timeout.
-
Fail-closed: New
requests.*,httpx.*,fetch(, or SDK stubs without a timeout fail CI.
A default somewhere in a shared library does not count unless the test proves this PR uses it.
2. Retries are bounded, jittered, and idempotent-only
- Gate: Retry count is finite. Backoff includes jitter. Non-idempotent POSTs do not retry unless the server contract is idempotent.
- Evidence: One retry policy module, plus a note that names retryable errors.
-
Fail-closed:
while Truearound a side effect, or “retry on any Exception,” fails the scan.
Unbounded retries turn a 10-second blip into a self-inflicted outage.
3. Idempotency keys for side effects
- Gate: Handlers that charge, email, enqueue, or create a unique external record accept an idempotency key and persist it before the side effect.
- Evidence: A store for keys, plus a test: two identical requests, one side effect.
- Fail-closed: No key column and no double-submit test, no merge.
AI will add a unique UUID per attempt. That is the opposite of idempotency. You want the client’s key, reused.
4. Poison-message policy for consumers
- Gate: Queue workers have a max receive count or a dead-letter path. A bad payload must not block the partition forever.
- Evidence: Max-retry config, DLQ name, and a test that a corrupt body is dead-lettered.
- Fail-closed: Infinite redelivery fails the gate.
5. Typed failure, not bare except
- Gate: Map retryable vs terminal errors. Logs include error class and a correlation id. Do not return success after a swallowed exception.
- Evidence: A small table in the PR: timeout → 504, conflict → 409, validation → 400.
-
Fail-closed: Bare
except:orexcept Exception: passin changed files fails.
6. Expand/contract for schemas
- Gate: Additive changes only in the same release. Readers tolerate unknown fields. Writers do not require new fields until old readers are gone.
- Evidence: A two-phase note, or a consumer test with extra JSON fields.
- Fail-closed: A new required field plus an old consumer in one PR, with no compatibility test.
7. Cancellation and shutdown
- Gate: In-flight work drains on SIGTERM. Loops respect a stop signal or a context deadline.
- Evidence: A shutdown test, or a grace period that matches the orchestrator.
- Fail-closed: A new long-running loop with no stop path.
8. Secrets stay out of scratch runs
- Gate: Local and sandbox runs use fixtures and fake credentials. Production secrets are not in the branch.
-
Evidence:
.env.examplewith placeholders; CI pulls from a secret store. - Fail-closed: Real tokens in the diff.
Eight gates. Not twenty. You can enforce them with one scanner and two tests. Architecture review still happens. These are the contracts AI PRs drop first.
Scanner: fail closed on obvious misses
This is a starting heuristic, not a proof of correctness. It will miss clever wrappers. It will flag comments. That is acceptable. Catch the obvious hang, then tighten.
Save as scripts/failure_contracts_scan.py:
#!/usr/bin/env python3
"""Fail closed on missing failure contracts. Heuristic, not an AST proof."""
from __future__ import annotations
import re
import sys
from pathlib import Path
SKIP_PARTS = {".git", "venv", ".venv", "node_modules", "__pycache__", "dist"}
PY_HTTP = re.compile(
r"\b(?:requests|httpx)\.(?:get|post|put|patch|delete|head|request)\s*\(",
re.I,
)
TIMEOUT = re.compile(r"\btimeout\s*=", re.I)
BARE_EXCEPT = re.compile(r"^\s*except\s*(?:Exception)?\s*:\s*(?:pass)?\s*$", re.M)
WHILE_TRUE = re.compile(r"^\s*while\s+True\s*:", re.M)
RETRY_HINT = re.compile(r"\bretry|retries|backoff\b", re.I)
SLEEP = re.compile(r"\b(?:time\.sleep|asyncio\.sleep)\s*\(", re.I)
def iter_files(root: Path):
for p in root.rglob("*"):
if not p.is_file() or p.suffix not in {".py", ".ts", ".js", ".go"}:
continue
if any(part in SKIP_PARTS for part in p.parts):
continue
yield p
def scan_file(path: Path) -> list[str]:
text = path.read_text(encoding="utf-8", errors="replace")
hits: list[str] = []
for i, line in enumerate(text.splitlines(), 1):
if PY_HTTP.search(line) and not TIMEOUT.search(line):
hits.append(f"{path}:{i}: outbound call without timeout= on this line")
if BARE_EXCEPT.match(line):
hits.append(f"{path}:{i}: bare except / except Exception")
if WHILE_TRUE.search(text) and SLEEP.search(text) and not RETRY_HINT.search(text):
hits.append(f"{path}: while True + sleep without a named retry policy")
return hits
def main(argv: list[str]) -> int:
root = Path(argv[1] if len(argv) > 1 else ".")
findings: list[str] = []
for path in iter_files(root):
findings.extend(scan_file(path))
if findings:
print("failure-contract scan FAILED (fail-closed)\n")
print("\n".join(findings))
return 1
print("failure-contract scan passed (heuristic only)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
Run it so a non-zero exit fails CI:
python scripts/failure_contracts_scan.py src
echo $?
# non-zero must fail the required check
Wire the same command as a required status check. If the script is missing, the job must fail. Skipping the scan is not an allowed outcome.
Minimal tests that back the scan
The scan catches shape. Tests catch behavior. If the PR touches HTTP or a consumer, add at least these two.
# tests/test_failure_contracts.py
# Proposal / example: adapt names to your client factory.
def test_http_client_sets_timeout():
from app.http import build_client
client = build_client()
timeout = getattr(client, "timeout", None)
assert timeout is not None, "client must expose a timeout"
# httpx uses Timeout; requests uses a float or tuple
seconds = getattr(timeout, "read", timeout)
assert float(seconds) > 0 and float(seconds) <= 30
def test_create_order_is_idempotent(client, db):
headers = {"Idempotency-Key": "k-test-1"}
body = {"sku": "sku-1", "qty": 1}
r1 = client.post("/orders", json=body, headers=headers)
r2 = client.post("/orders", json=body, headers=headers)
assert r1.status_code in (200, 201)
assert r2.status_code in (200, 201)
assert r1.json()["id"] == r2.json()["id"]
assert db.count_orders() == 1
Label those tests as examples until you hook them to your real app. Keep the assertions. If you only have time for two tests, keep the timeout assertion and the double-submit test.
Required check in CI
# .github/workflows/failure-contracts.yml
name: failure-contracts
on:
pull_request:
paths: ["src/**", "app/**", "scripts/failure_contracts_scan.py"]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: fail closed if scanner missing
run: test -f scripts/failure_contracts_scan.py
- name: scan
run: python scripts/failure_contracts_scan.py src
If your source lives under app/ instead of src/, point the scan at that tree. Do not silently scan an empty directory.
Decision table: merge or not
| Gate | Evidence in the PR | Test or scan proves it | Decision |
|---|---|---|---|
| Timeouts | file:line list | scan + client test | merge only if both |
| Retries | single policy module | no unbounded loop | merge only if both |
| Idempotency | key stored first | double-submit test | merge only if both |
| Poison / DLQ | max receive + DLQ name | bad-payload test | merge only if both |
| Typed errors | status mapping table | no bare except | merge only if both |
| Schema | two-phase note | extra-field test | merge only if both |
| Shutdown | grace period documented | stop-signal path | merge only if both |
| Secrets | placeholders only | no tokens in diff | merge only if both |
Read the last column. Review comments do not override a missing artifact.
Run the loop off the production cluster
You want a throwaway clone, a scratch process, and a model that can add the timeouts you just failed. You do not want that loop on the production cluster or on a laptop full of real .env files.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode is an open-source project with operator-supplied free model access and a free server option. Use that pair as a sandbox: clone the branch, run the scanner, patch deadlines, re-run the two tests. Treat the free server as untrusted compute. Do not place production secrets, customer payloads, or private keys there. Do not treat free compute as a production SLA.
This article does not list model names, quotas, hardware, or uptime. Those claims go stale. The workflow does not: fail the scan, patch, re-run, then promote the diff into your real CI. A sandbox pass is not a production pass. Keep the same required check in GitHub Actions or GitLab CI.
Limitations
- The scanner is pattern-based. A custom
HttpClientthat hides timeouts will look clean and still hang. - An idempotency test does not prove exactly-once delivery across crashes. It proves you stored a key and reused the result.
- Expand/contract needs two deploy waves. One PR cannot encode the second wave.
- Free-tier sandboxes share fate with whoever operates them. Outages, noisy neighbors, and prompt-side logging are assumptions you must accept.
- This checklist does not cover authz, injection, XSS, or load. Do not pretend it does.
Ship the scanner knowing it is a net, not a proof. Pair it with review on any new client wrapper.
Who should not use this approach
- Teams that ignore red CI will collect failed gates and merge anyway. The ritual wastes time. Fix the release policy first.
- Regulated workloads that forbid sending source to third-party models should not use a hosted free-model sandbox. Run the scanner locally.
- Repos that are not services — docs, one-off scripts, static sites — do not need DLQ gates. Use timeouts and secrets only.
- If your language is not easy to grep this way, write equivalent linter or compiler rules. Do not cargo-cult the Python file.
Monday setup
- Paste the eight gates into the PR template, with the evidence column visible.
- Land the scanner as a required check on one service, not the whole monorepo.
- Require the timeout test and the double-submit test for any PR that touches outbound I/O.
- Keep production credentials out of any free server you use for the edit loop.
You are not asking a model to “write more tests.” You are refusing to merge code that cannot fail in a bounded way. That policy is cheaper than a post-incident review of hanging workers.
Top comments (0)