DEV Community

Finley Zhu
Finley Zhu

Posted on

Workshop: Run a 100-Minute Fault-Injection Drill Against a Free Model Endpoint

Free model endpoints rarely fail the way client libraries assume, and most teams learn that in production rather than in a classroom. A documented rate limit you can back off from, a stream that dies mid-token, and a 200 response with an empty choices array all look identical to a naive retry wrapper. This workshop spends 100 minutes teaching participants to separate those cases before writing a single line of retry logic, using only a local stub so no live quota is consumed.

What participants leave with

The deliverable is not a library, it is a repeatable diagnostic habit plus four artifacts they can rerun on any endpoint.

  • A local fault server that reproduces six distinguishable failure modes on demand.
  • A classifier that maps raw responses to named outcomes instead of a boolean.
  • A retry ladder with an explicit wall-clock budget and per-attempt accounting.
  • A decision table that says, for each class, whether retrying is even meaningful.

The failure taxonomy students must reproduce

The whole drill rests on one claim: classification is cheaper than retrying, and most retry bugs are classification bugs. Write this table on the board before any code appears.

Observed on the wire Named class Retry meaningful? Why
429 with Retry-After rate_limited Yes, after the stated wait The server told you when
Socket read exceeds timeout timeout Yes, with a longer timeout Capacity, not correctness
5xx from an intermediary server_error Usually yes, bounded Upstream may be restarting
200 with unparseable JSON truncated Sometimes Stream cut, not a refusal
200 with choices: [] empty Policy decision Silent filter or bad request shape
4xx other than 429 client_error No Your payload is wrong

Timing plan for the 100 minutes

Minutes Block Output
0-5 Framing and taxonomy on the board Shared vocabulary
5-20 Exercise 1: stub server up fault_server.py responding
20-40 Exercise 2: classifier call_once() returning classes
40-60 Exercise 3: retry ladder Budget-aware run()
60-75 Exercise 4: worked trace One request, annotated
75-90 Exercise 5: score sheet Filled results table
90-100 Debrief and decision table Agreed retry policy

Exercise 1: stand up the fault server (15 minutes)

The stub below uses only the Python standard library, so a room of students needs no package installs and no network. Treat it as a reference implementation and check it against your interpreter version before the session.

# fault_server.py -- stdlib only, six modes, random per request
import json, random, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

MODES = ["ok", "rate_limited", "timeout", "truncated", "empty", "slow_ok"]

class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass  # keep the classroom terminal readable

    def do_POST(self):
        self.rfile.read(int(self.headers.get("Content-Length", 0)))
        mode = random.choice(MODES)
        if mode == "rate_limited":
            self.send_response(429)
            self.send_header("Retry-After", "2")
            self.end_headers()
        elif mode == "timeout":
            time.sleep(30)          # client must give up first
            self.send_response(504)
            self.end_headers()
        elif mode == "truncated":
            self.send_response(200)
            self.send_header("Content-Type", "text/event-stream")
            self.end_headers()
            self.wfile.write(b'data: {"delta":"part')
        elif mode == "empty":
            self._json({"choices": []})
        elif mode == "slow_ok":
            time.sleep(3)
            self._json({"choices": [{"message": {"content": "ok"}}]})
        else:
            self._json({"choices": [{"message": {"content": "ok"}}]})

    def _json(self, body):
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(body).encode())

if __name__ == "__main__":
    ThreadingHTTPServer(("127.0.0.1", 8099), Handler).serve_forever()
Enter fullscreen mode Exit fullscreen mode

Run it with python fault_server.py, then confirm with curl -s -X POST localhost:8099/v1/chat/completions -d '{}' repeated a dozen times. Ask students to record which modes they saw, because a single run usually shows three of six.

Debrief question for this block

Why does the timeout mode sleep 30 seconds when the client timeout is 10? Because the drill is testing the client's patience, not the server's speed, and a shared sleeping thread is cheaper than a fault-injection framework.

Exercise 2: classify before you retry (20 minutes)

The classifier must return a named outcome, never a boolean, and it must survive a response body that is not valid JSON.

# probe.py
import json, socket, urllib.error, urllib.request

class Outcome:
    def __init__(self, kind, detail=""):
        self.kind, self.detail = kind, detail

    def __repr__(self):
        return f"<{self.kind} {self.detail}>"

def classify_ok(raw: bytes) -> Outcome:
    try:
        body = json.loads(raw)
    except json.JSONDecodeError:
        return Outcome("truncated", raw[-24:].decode(errors="replace"))
    choices = body.get("choices") or []
    if not choices:
        return Outcome("empty")
    content = (choices[0].get("message") or {}).get("content") or ""
    return Outcome("empty_content") if not content.strip() else Outcome("ok", content[:24])

def call_once(url, payload, timeout):
    req = urllib.request.Request(
        url, data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"}, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return classify_ok(r.read())
    except urllib.error.HTTPError as e:
        if e.code == 429:
            return Outcome("rate_limited", e.headers.get("Retry-After") or "")
        if e.code >= 500:
            return Outcome("server_error", str(e.code))
        return Outcome("client_error", str(e.code))
    except (socket.timeout, TimeoutError):
        return Outcome("timeout")
    except urllib.error.URLError as e:
        return Outcome("transport", str(e.reason))
Enter fullscreen mode Exit fullscreen mode

Students then fire 30 requests at the stub and tally outcomes by name. The expected lesson is that truncated and empty arrive with a healthy status code, so any wrapper keyed on status alone will silently convert them into successes.

Exercise 3: the retry ladder with a budget (20 minutes)

A ladder without a budget is a loop, and a loop against a free endpoint is how a demo turns into a ban. Make the wall-clock ceiling a parameter and charge every attempt against it.

import random, time

LADDER = [
    (1, 10, 0.0),   # attempt, timeout_s, pre-wait_s
    (2, 10, 2.0),   # honor Retry-After here when present
    (3, 20, 8.0),   # widen the read timeout once
]

def run(url, payload, budget_s=45.0):
    started, history = time.monotonic(), []
    for attempt, timeout, wait in LADDER:
        jitter = random.uniform(0, 1)
        if time.monotonic() - started + wait + jitter + timeout > budget_s:
            history.append(("budget_exhausted", attempt))
            break
        time.sleep(wait + jitter)
        out = call_once(url, payload, timeout)
        history.append((out.kind, attempt))
        if out.kind == "ok":
            return out, history
        if out.kind == "client_error":
            break  # payload is wrong; more attempts cannot help
    return None, history
Enter fullscreen mode Exit fullscreen mode

Require one design decision in writing: does empty get a second attempt or not? Both answers are defensible, and the drill grades the justification rather than the choice.

Exercise 4: a worked trace students can rerun (15 minutes)

Walk one request through the ladder on the board, step by step, so the accounting is visible.

  1. Attempt 1, timeout 10s. The stub picks rate_limited and returns 429 with Retry-After: 2. Charge 10s of budget at worst, much less in practice.
  2. Wait 2s plus jitter, because the header outranks the ladder's default. Attempt 2, timeout 10s.
  3. The stub now picks truncated. json.loads raises, the class is truncated, and the ladder continues because a cut stream is not a refusal.
  4. Attempt 3, timeout 20s, after an 8s plus jitter wait. The stub returns ok, and the trace ends with a success on attempt three.
  5. Total charged time stays under the 45s ceiling, and the history list shows exactly which class each attempt hit.

Then rerun with budget_s=12 and watch the ladder abandon early. The point is that the ceiling, not the attempt count, is what protects the endpoint.

Exercise 5: the score sheet and decision table (15 minutes)

Students fill the same table for two ladders of their own design, and the empty cells are the deliverable.

Ladder Requests ok rate_limited timeout truncated empty Resolved within budget
A: 3 attempts, 45s 60
B: 1 attempt, 15s 60

The debrief question is whether ladder B's failures were cheaper to handle downstream than ladder A's extra wall-clock time. Close the session by agreeing on one retry policy per failure class and writing it into the repository, not into a slide.

Where a free endpoint fits into the exercise

The stub proves the logic, but students still need one real endpoint to feel the difference between a simulated 429 and a shared one. MonkeyCode's free model access and free server option are useful here precisely because the drill is budget-aware: the operator's own materials describe a large free token allowance and a no-cost server, and you should confirm the current terms and limits on the project's own pages before the session rather than trusting any secondhand number.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Because the ladder caps total wall-clock time, a workshop can rehearse against a free endpoint without letting a runaway loop drain a shared allowance. Keep the stub as the default target and treat the hosted endpoint as the final exercise only.

Limitations and who should skip this

  • The stub cannot reproduce provider-specific quirks such as tool-call schema drift, safety filters, or tokenizer-level truncation, so it is a starting taxonomy rather than a complete one.
  • Retrying never repairs a wrong prompt; empty and empty_content often mean the request shape is wrong, not that the server is busy.
  • The ladder is client-side only. If your workflow writes to a database, pair it with idempotency keys, or the drill will teach the wrong lesson.
  • Teams with hard latency SLOs should not adopt a multi-second ladder without measuring it first, and anyone who cannot verify the current free-tier terms should run the stub only.

If you teach this drill, keep the fixture local, keep the budget explicit, and keep the classification table on the wall. When you want a second endpoint to compare ladders against, the MonkeyCode project is a reasonable place to start, and its own documentation is the source of truth for what the free access currently includes.

Top comments (0)