DEV Community

Taylor Zhu
Taylor Zhu

Posted on

Timeouts Are Not Optional: A Failure-Contract Checklist for AI PRs

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 True around 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: or except Exception: pass in 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.example with 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))
Enter fullscreen mode Exit fullscreen mode

Run it so a non-zero exit fails CI:

python scripts/failure_contracts_scan.py src
echo $?
# non-zero must fail the required check
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 HttpClient that 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

  1. Paste the eight gates into the PR template, with the evidence column visible.
  2. Land the scanner as a required check on one service, not the whole monorepo.
  3. Require the timeout test and the double-submit test for any PR that touches outbound I/O.
  4. 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)