← All guides

Mock a REST API for Gradio β€” build the ML demo before the model API exists

Gradio apps are the front of a model that lives somewhere else: an inference endpoint the ML team hasn't deployed yet, a GPU box that costs real money per call, an internal API you can only reach on the VPN. You still want to design the demo today β€” with realistic latency, an error toast that isn't an afterthought, and data your teammates and CI can see too. Hard-coding examples= only gets you so far; a Space that calls a dead URL gets you a stack trace on stage.

The boring fix is a hosted mock API. Every snippet below was executed verbatim against the live service before publishing β€” the event handlers under a pytest-style harness and a real demo.launch() exercised through gradio_client (details in the verified note).

1. A working dashboard in 20 lines (10 seconds, no signup)

The public demo project ships 30 seeded products. Gradio is event-driven β€” demo.load fires your fetch when the page opens:

# app.py  β€”  pip install gradio pandas requests  β†’  python app.py
import gradio as gr
import pandas as pd
import requests

BASE = "https://mockbird.mockbird.workers.dev/m/demo"

def load_products():
    rows = requests.get(f"{BASE}/products", params={"limit": 100}, timeout=10).json()
    df = pd.DataFrame(rows)
    summary = f"**{len(df)} products** Β· average price ${df['price'].mean():.2f}"
    return summary, df[["name", "price", "category", "inStock", "rating"]], df

with gr.Blocks(title="Product dashboard") as demo:
    gr.Markdown("# Product dashboard")
    summary = gr.Markdown()
    table = gr.Dataframe(label="Products")
    chart = gr.BarPlot(x="category", y="price", y_aggregate="mean",
                       label="Average price by category")
    demo.load(load_products, outputs=[summary, table, chart])

if __name__ == "__main__":
    demo.launch()

That renders a summary line (30 products Β· average price $410.98 when we ran it), a sortable gr.Dataframe and a per-category gr.BarPlot β€” against a URL that also works from your teammate's laptop, a Hugging Face Space, and CI. Want your own data instead of our demo? One curl:

curl -X POST "https://mockbird.mockbird.workers.dev/api/projects?preset=ecommerce"

copy baseUrl from the response, change BASE, done. Or create it in one click.

2. Your CSV is already an API

Most demo data starts life as a CSV. Promote the file to a real typed REST API β€” one curl, no signup:

printf 'name,role,salary,remote
Ada Lovelace,Engineer,185000,true
Grace Hopper,Admiral,190000,false
Katherine Johnson,Analyst,170000,true
' | curl -X POST --data-binary @- -H "content-type: text/csv" \
  "https://mockbird.mockbird.workers.dev/api/projects/import?resource=people&name=team-api"

Columns are typed from the data (salary arrives as a real number, remote a boolean β€” we verified the dtype end-to-end in pandas: int64), so ?salary_gte=180000, ?remote=true, sortBy/order and q= full-text search work immediately. Keep the adminKey from the response β€” you'll want it in Β§4. Full details in the CSV β†’ REST API guide.

3. A form that really writes

Public fake APIs (JSONPlaceholder, FakeStoreAPI, DummyJSON) fake their writes β€” POST answers 201 and the record was never stored, so your submit button quietly lies. Here writes persist. Using the people project from Β§2 (swap in your project id):

import gradio as gr
import pandas as pd
import requests

BASE = "https://mockbird.mockbird.workers.dev/m/YOUR_PROJECT_ID"

def load_people():
    rows = requests.get(f"{BASE}/people",
                        params={"sortBy": "salary", "order": "desc"},
                        timeout=10).json()
    return pd.DataFrame(rows)

def add_person(name, role, salary, remote):
    r = requests.post(f"{BASE}/people",
                      json={"name": name, "role": role,
                            "salary": int(salary), "remote": bool(remote)},
                      timeout=10)
    r.raise_for_status()
    return load_people(), "", "", 0, False   # refreshed table + cleared form

with gr.Blocks(title="Team dashboard") as demo:
    gr.Markdown("# Team dashboard")
    table = gr.Dataframe(label="People")
    with gr.Row():
        name = gr.Textbox(label="Name")
        role = gr.Textbox(label="Role")
        salary = gr.Number(label="Salary", value=0)
        remote = gr.Checkbox(label="Remote")
    add = gr.Button("Add person", variant="primary")
    add.click(add_person, inputs=[name, role, salary, remote],
              outputs=[table, name, role, salary, remote])
    demo.load(load_people, outputs=table)

if __name__ == "__main__":
    demo.launch()

We ran exactly this: submitted Annie Easley through the handler, the table re-rendered with 4 rows β€” then fetched /people/4 with plain requests from outside Gradio and got Annie back, remote: true and all. It's a real record on a real URL, visible to anyone you share the link with.

4. Mock the model itself β€” a fake inference endpoint with GPU-ish latency

This is the Gradio-specific trick. Your real backend probably isn't CRUD β€” it's POST /predict. Custom routes let you mock that exact contract, templated from the request body, with deliberate latency baked in. Using the project + adminKey from Β§2:

curl -X POST -H "content-type: application/json" -H "x-admin-key: YOUR_ADMIN_KEY" \
  "https://mockbird.mockbird.workers.dev/api/projects/YOUR_PROJECT_ID/routes" \
  -d '{"method":"POST","path":"/predict","status":200,"delayMs":1200,
       "body":"{\"label\": \"positive\", \"score\": 0.{{rand}}, \"input\": \"{{body.text}}\", \"model\": \"sentiment-v2-mock\"}"}'

{{body.text}} echoes the caller's input back (JSON-escaped β€” quotes in the input can't break the payload; we tested exactly that), {{rand}} varies the score per call, and delayMs: 1200 makes every prediction take about as long as a real model server. Now the classic gr.Interface demo works before the model exists:

import gradio as gr
import requests

PREDICT_URL = "https://mockbird.mockbird.workers.dev/m/YOUR_PROJECT_ID/predict"

def classify(text):
    r = requests.post(PREDICT_URL, json={"text": text}, timeout=15)
    r.raise_for_status()
    out = r.json()
    return out["label"], out["score"]

demo = gr.Interface(
    classify,
    inputs=gr.Textbox(label="Text to classify"),
    outputs=[gr.Label(label="Sentiment"), gr.Number(label="Score")],
    title="Sentiment demo (mock model)",
)

if __name__ == "__main__":
    demo.launch()

Measured when we ran it: 1.76 s per prediction β€” a real 1.2 s server-side delay plus network, so Gradio's built-in pending animation is genuinely on screen, exactly like it will be in production. When the real endpoint ships, change one URL. (Mocking an LLM chat backend instead? See the mock OpenAI-compatible API guide.)

5. Real loading states

On localhost every fetch resolves in milliseconds, so nobody sees Gradio's pending state until the app is deployed and the API is slow. Ask the mock to be slow: add "mock_delay": 3000 to any request's params and the same endpoint takes a real 3 seconds (we measured 3.8 s wall clock including network) β€” while the handler runs, Gradio greys out the output components and shows its progress indicator, so you can judge whether the demo feels broken and where you need a gr.Progress bar instead.

6. The error branch you never test β€” gr.Error toasts, on demand

import gradio as gr
import pandas as pd
import requests

BASE = "https://mockbird.mockbird.workers.dev/m/demo"
FORCE_STATUS = 500   # flip to 0 to load normally

def load_products():
    params = {"limit": 100}
    if FORCE_STATUS:
        params["mock_status"] = FORCE_STATUS
    r = requests.get(f"{BASE}/products", params=params, timeout=10)
    if not r.ok:
        raise gr.Error(f"The products API answered {r.status_code}. "
                       "Hit Retry β€” showing nothing beats showing something wrong.")
    return pd.DataFrame(r.json())

with gr.Blocks() as demo:
    table = gr.Dataframe(label="Products")
    retry = gr.Button("Retry")
    retry.click(load_products, outputs=table)
    demo.load(load_products, outputs=table)

if __name__ == "__main__":
    demo.launch()

?mock_status=500 makes the same endpoint answer a real HTTP 500 (any code 100–599 works: 401 for expired keys, 429 for rate limits, 503 for the GPU box rebooting). An uncaught exception in a handler shows users a generic β€œError” toast β€” raising gr.Error with your own message is the difference between a demo that explains itself and one that looks broken. Design that toast on purpose.

7. Deterministic retry drills

Chaos that fails randomly is bad for demos. ?mock_seq=500,500,200 serves a deterministic sequence: first request a real 500, second a real 500, third (and after) the real data β€” so a retry loop provably earns its keep:

import gradio as gr
import pandas as pd
import requests

BASE = "https://mockbird.mockbird.workers.dev/m/demo"

def load_with_retry(attempts=3):
    for attempt in range(1, attempts + 1):
        r = requests.get(f"{BASE}/products",
                         params={"limit": 5, "mock_seq": "500,500,200",
                                 "mock_seq_key": "gradio-retry-demo"},
                         timeout=10)
        if r.ok:
            return (f"Loaded {len(r.json())} products on attempt "
                    f"{attempt} of {attempts}"), pd.DataFrame(r.json())
    r.raise_for_status()

with gr.Blocks() as demo:
    status = gr.Markdown()
    table = gr.Dataframe()
    demo.load(load_with_retry, outputs=[status, table])

if __name__ == "__main__":
    demo.launch()

Run fresh, the app reports β€œLoaded 5 products on attempt 3 of 3”. We verified the mechanism precisely: four raw requests with a fresh key answered 500, 500, 200, 200 with x-mockbird-seq: 1/3 … 3/3 headers. On the shared demo the counter is scoped per visitor IP, so this snippet works for every reader independently; once a sequence is spent it sticks on the last entry β€” add &mock_seq_reset=1 to one request to run the drill again.

8. The empty state, on demand

rows = requests.get(f"{BASE}/products",
                    params={"mock_snapshot": "empty"}, timeout=10).json()
if not rows:
    ...   # show your designed empty state, not a blank Dataframe

?mock_snapshot=empty serves the same endpoint read-only from a saved snapshot β€” the demo ships empty and edge-cases (100-char names, unicode, price 0) built in. A gr.Dataframe bound to an empty list renders a bare grid that looks like a bug; we verified the branch above returns [] so you can swap in a designed gr.Markdown message instead. On your own project, save any number of named scenarios and pin each demo tab or test to a different one, without touching live data.

9. Test it β€” pytest on handlers, gradio_client on the running app

Gradio handlers are plain Python functions, so unit tests need no harness at all β€” call them. For integration tests, gradio_client drives a really-launched app over HTTP, exactly like the Space will be used:

# test_app.py  β€”  pip install pytest gradio_client  β†’  pytest test_app.py
from gradio_client import Client

import dashboard   # the Β§1 app


def test_handler_directly():
    summary, table, _ = dashboard.load_products()
    assert "30 products" in summary          # demo reseeds to exactly 30
    assert len(table) == 30


def test_running_app_end_to_end():
    dashboard.demo.launch(prevent_thread_lock=True, server_port=7899, quiet=True)
    client = Client("http://127.0.0.1:7899", verbose=False)
    summary, table, _ = client.predict(api_name="/load_products")
    assert "30 products" in summary
    assert len(table["data"]) == 30
    dashboard.demo.close()

Both passed against production when we ran them (the client returns a gr.Dataframe payload as {"headers": …, "data": …}). The == 30 asserts hold only because the demo reseeds to exactly 30 products β€” on your own project, pin tests to a named snapshot so a teammate's writes can't flake your CI.

10. Honest comparison

Hard-coded examples= / fixturesresponses / requests-mockMockbird
What it isStatic data inside the appPatches requests in-process (tests only)Hosted mock API
App code is production-shaped (fetches a URL)βœ– rewrite before shipβœ” in tests, βœ– for python app.py / Spacesβœ” swap one base URL to ship
Works offline / zero latencyβœ”βœ”βœ– (real network)
Mocks a POST /predict contract with latencyβœ–Simulated at bestβœ” custom routes + delayMs
Writes persist (forms, feedback buttons)βœ–βœ–βœ”
Pending / error / retry states are realβœ–Simulated at bestβœ” mock_delay/mock_status/mock_seq
Same data visible to teammates, CI, the SpaceFiles drift apartβœ– one processβœ” one https URL
Free tierfreefree (library)free: 20 projects, 10k req/project/day

Use all three where they're strongest: examples= for instant offline sketching, responses for millisecond unit tests, and a hosted URL the moment the demo needs to look and fail like production. Building in Streamlit instead? The Streamlit version of this guide. In R/Shiny? The R version. More on the Python testing side: mock APIs for Python.

11. Ship day

Mockbird follows plain REST conventions, so the swap to the real backend is one line β€” read BASE from an environment variable (or a Space secret). Until then, hand your ML team the contract while they build it: /openapi.json, a Postman collection and TypeScript types are generated from your live schema.

Verified: every snippet on this page was executed verbatim against the production service on Sep 21, 2026 with Gradio 6.28 β€” a 16-assertion harness over the handlers (rendered summaries/tables, the form write read back from outside Gradio, measured delay and model-latency timings, the exact 500,500,200,200 sequence pattern, the gr.Error raise, the empty-state branch, the templated /predict echo including a quotes-in-input injection check) plus a real demo.launch() driven end-to-end through gradio_client. The harness substitutes exactly two things: a self-test header injected at the transport level so our own runs are excluded from usage stats, and the placeholder project id.

Also mocking for a frontend or another service? The same project feeds Streamlit, React, Python, FastAPI, Flask, Django and Jest at once β€” one dataset for the whole team. Docs β†’
⚑ Skip the terminal: this link creates a live, seeded backend (products, orders, customers, reviews) in the dashboard β€” real URL, no signup. Or import your own CSV, OpenAPI spec, db.json, Postman collection, or HAR and mock your exact shapes.