n4nAI

Simulating rate limits and errors in a local LLM mock

Learn how to build a local LLM mock that simulates rate limits and errors, so you can test client retry and fallback logic without real API calls.

n4n Team3 min read630 words

Audio narration

Coming soon — every post will get a voice note here.

You can’t reliably test retry backoff or fallback logic against a live LLM provider—their rate limits are unpredictable and hitting them costs money. To simulate rate limits local llm mock servers let you reproduce 429s, 500s, and timeouts on demand, so your client behaves correctly before production. This guide walks through building one with FastAPI and wiring it into a test suite.

Step 1: Stand up a minimal OpenAI-compatible mock

Start with a server that speaks the same shape as the OpenAI completions endpoint. Most LLM gateways, including n4n.ai, expose an OpenAI-compatible route, so a mock that matches the contract lets you swap the base URL without changing client code.

We’ll use FastAPI because its async model makes latency and concurrency simulation straightforward.

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn

app = FastAPI()

@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
    body = await request.json()
    return {
        "id": "mock-1",
        "object": "chat.completion",
        "model": body.get("model", "gpt-3.5-turbo"),
        "choices": [{
            "index": 0,
            "message": {"role": "assistant", "content": "echo"},
            "finish_reason": "stop"
        }],
        "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
    }

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8080)

Save as mock.py and run python mock.py. Verify with a curl:

curl -X POST http://127.0.0.1:8080/v1/chat/completions \
  -H "content-type: application/json" \
  -d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"hi"}]}'

You should get a JSON completion. That’s the baseline; now we break it on purpose.

Step 2: Add deterministic rate limit simulation

To simulate rate limits local llm mock must track request counts per identity and reject excess with a proper 429 and Retry-After. A sliding window per API key is enough for local testing—no need for Redis.

from datetime import datetime, timedelta
from collections import defaultdict

RATE_LIMIT = 5
WINDOW = timedelta(seconds=10)
hits = defaultdict(list)

@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
    api_key = request.headers.get("authorization", "anon")
    now = datetime.utcnow()
    hits[api_key] = [t for t in hits[api_key] if now - t < WINDOW]
    if len(hits[api_key]) >= RATE_LIMIT:
        retry_after = int((hits[api_key][0] + WINDOW - now).total_seconds())
        return JSONResponse(
            status_code=429,
            content={"error": {"message": "Rate limit exceeded", "type": "rate_limit_error"}},
            headers={"Retry-After": str(max(retry_after, 1))}
        )
    hits[api_key].append(now)
    body = await request.json()
    return {
        "id": "mock-1",
        "object": "chat.completion",
        "model": body.get("model", "gpt-3.5-turbo"),
        "choices": [{
            "index": 0,
            "message": {"role": "assistant", "content": "echo"},
            "finish_reason": "stop"
        }],
        "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
    }

Push RATE_LIMIT and WINDOW into environment variables so tests can tighten limits without code edits. A low limit like 3 requests per 5 seconds makes test suites fast.

Verify the limit by looping requests:

import requests
url = "http://127.0.0.1:8080/v1/chat/completions"
headers = {"authorization": "Bearer test", "content-type": "application/json"}
for i in range(3):
    assert requests.post(url, json={"model":"x","messages":[]}, headers=headers).status_code == 200
r = requests.post(url, json={"model":"x","messages":[]}, headers=headers)
assert r.status_code == 429 and "Retry-After" in r.headers

Step 3: Inject errors and latency via headers

When you simulate rate limits local llm mock, you also want to trigger 500s, 503s, and slow responses without changing server state. Header-driven fault injection keeps the mock stateless and reproducible.

Extend the handler:

import asyncio
from fastapi.responses import StreamingResponse

@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
    api_key = request.headers.get("authorization", "anon")
    now = datetime.utcnow()
    hits[api_key] = [t for t in hits[api_key] if now - t < WINDOW]
    if len(hits[api_key]) >= RATE_LIMIT:
        retry_after = int((hits[api_key][0] + WINDOW - now).total_seconds())
        return JSONResponse(
            status_code=429,
            content={"error": {"message": "Rate limit exceeded", "type": "rate_limit_error"}},
            headers={"Retry-After": str(max(retry_after, 1))}
        )
    hits[api_key].append(now)

    mock_error = request.headers.get("x-mock-error")
    if mock_error:
        code = int(mock_error)
        return JSONResponse(status_code=code, content={"error": {"message": f"mock {code}"}})

    mock_latency = int(request.headers.get("x-mock-latency", 0))
    if mock_latency:
        await asyncio.sleep(mock_latency / 1000)

    if request.headers.get("x-mock-stream") == "true":
        async def gen():
            yield b'data: {"choices":[{"delta":{"content":"a"}}]}\n\n'
            if request.headers.get("x-mock-stream-fail") == "true":
                return
            yield b'data: {"choices":[{"delta":{"content":"b"}}]}\n\n'
            yield b'data: [DONE]\n\n'
        return StreamingResponse(gen(), media_type="text/event-stream")

    body = await request.json()
    return {
        "id": "mock-1",
        "object": "chat.completion",
        "model": body.get("model", "gpt-3.5-turbo"),
        "choices": [{
            "index": 0,
            "message": {"role": "assistant", "content": "echo"},
            "finish_reason": "stop"
        }],
        "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
    }

For streaming endpoints, x-mock-stream-fail sends a few tokens then closes the connection mid-stream. That exercises client-side stream recovery. A client can force a 503 with headers={"x-mock-error":"503"} or a 2-second delay with x-mock-latency: 2000.

Step 4: Drive the mock from a real LLM client

Point the OpenAI Python SDK at the mock by setting base_url. This validates that your production client code—including any middleware—handles failures correctly.

from openai import OpenAI

client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="test")

def complete_with_retry(max_retries=3):
    for attempt in range(max_retries):
        try:
            resp = client.chat.completions.create(
                model="gpt-3.5-turbo",
                messages=[{"role":"user","content":"hi"}],
                extra_headers={"x-mock-error": "429"} if attempt == 0 else {}
            )
            return resp.choices[0].message.content
        except Exception as e:
            if attempt == max_retries - 1:
                raise
            import time
            time.sleep(2 ** attempt)

If you use the SDK’s built-in max_retries, set it to 0 in tests and implement your own backoff, or let the SDK handle it and assert on final success. The key is that the mock supplies the failure; the client supplies the resilience.

Step 5: Write automated tests that prove resilience

The point of simulate rate limits local llm mock is to fail in CI if retry logic regresses. Use pytest with an httpx fixture so you control headers precisely.

import pytest, asyncio, httpx

@pytest.fixture
def mock_url():
    return "http://127.0.0.1:8080/v1/chat/completions"

def test_rate_limit_then_success(mock_url):
    headers = {"authorization":"Bearer t", "content-type":"application/json"}
    # assume RATE_LIMIT=2, WINDOW=1s set via env
    for _ in range(2):
        assert httpx.post(mock_url, json={"model":"x","messages":[]}, headers=headers).status_code == 200
    r = httpx.post(mock_url, json={"model":"x","messages":[]}, headers=headers)
    assert r.status_code == 429
    asyncio.sleep(1)
    assert httpx.post(mock_url, json={"model":"x","messages":[]}, headers=headers).status_code == 200

def test_500_triggers_retry(mock_url):
    headers = {"authorization":"Bearer t", "content-type":"application/json", "x-mock-error":"500"}
    r = httpx.post(mock_url, json={"model":"x","messages":[]}, headers=headers)
    assert r.status_code == 500

Run pytest. Green means your client will survive real provider hiccups. Add a streaming test that asserts x-mock-stream-fail raises a connection error in the client and that your code reconnects or falls back.

Step 6: Validate fallback and cache-control behavior

Once the mock proves your client retries, extend it to emulate multiple providers behind one route. Return different errors based on x-mock-provider header, and include cache-control hints to confirm your client forwards them.

If you later point the same client at n4n.ai, its automatic fallback when a provider is rate-limited or degraded will exercise the same retry paths you validated locally. The gateway honors client routing directives and forwards provider cache-control hints, so the mock’s cache-control header should be passed through unchanged.

Add a check in the mock:

@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
    # ... rate limit and error logic above ...
    body = await request.json()
    response = JSONResponse(content={
        "id": "mock-1",
        "object": "chat.completion",
        "model": body.get("model", "gpt-3.5-turbo"),
        "choices": [{"index": 0, "message": {"role": "assistant", "content": "echo"}, "finish_reason": "stop"}],
        "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
    })
    if request.headers.get("x-mock-cache"):
        response.headers["cache-control"] = "max-age=60"
    return response

Then assert in tests that your client copies that header to its upstream calls. This catches bugs where a caching proxy or gateway hint is silently dropped.


Operational notes: run the mock in a Docker container in CI with ENV RATE_LIMIT=2 WINDOW=1. Kill it after tests. Because the mock is stateless except for the in-memory counter, restarting between test modules avoids cross-test contamination.

Building a local fault-injecting mock takes an hour and removes guesswork from resilience testing. You now have a repeatable way to simulate rate limits local llm mock errors and confirm your client does the right thing every time.

Tagsmockingrate-limitstestinglocal-dev

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All local dev & mocking llm apis posts →