Streamlit apps are usually the front of something: a data API a platform team is still building, an internal service you can only reach on the VPN, a third-party API with a rate limit that hates your rerun-the-whole-script-on-every-widget-change execution model. st.connection has you covered for SQL and Snowflake β but when the data source is a REST API, you're on plain requests, and you need that API to exist, tolerate a whole team hammering it, and be able to act broken on demand so your spinner, error and empty states aren't designed in production.
The boring fix is a hosted mock API. Every snippet below was executed verbatim against the live service before publishing β through Streamlit's own AppTest harness plus a real streamlit run browser check (details in the verified note).
The public demo project ships 30 seeded products. Point requests + pandas at it:
# app.py β pip install streamlit pandas requests β streamlit run app.py
import pandas as pd
import requests
import streamlit as st
BASE = "https://mockbird.mockbird.workers.dev/m/demo"
st.title("Product dashboard")
rows = requests.get(f"{BASE}/products", params={"limit": 100}, timeout=10).json()
df = pd.DataFrame(rows)
st.metric("Products", len(df))
st.metric("Average price", f"${df['price'].mean():.2f}")
st.bar_chart(df.groupby("category")["price"].mean())
st.dataframe(df[["name", "price", "category", "inStock", "rating"]])
That renders two st.metric cards (30 products, average price), a per-category bar chart and a sortable table β against a URL that also works from your teammate's laptop, a notebook, 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.
pd.read_csv("https://mockbird.mockbird.workers.dev/m/demo/products?mock_format=csv&select=name,price,category&limit=100", storage_options={"User-Agent": "my-streamlit-app"}) β a typed 30Γ4 DataFrame (we ran it). The storage_options part matters: pandas fetches URLs with Python's default urllib User-Agent, which Cloudflare's edge blocks platform-wide with a 403 (documented gotcha) β any custom UA fixes it.Half of all Streamlit apps start life as pd.read_csv("some_file.csv"). Promote the file to a real typed REST API instead β 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 is a number, remote a boolean β we verified both), so the query toolkit works immediately: ?salary_gte=180000 returns exactly the two high earners, ?remote=true the two remote folks, plus sortBy/order/_page/q= full-text search. Full details in the CSV β REST API guide.
Public fake APIs (JSONPlaceholder, FakeStoreAPI, DummyJSON) fake their writes β POST answers 201 and the record was never stored, so an st.form demo quietly lies to you. Here writes persist. Using the people project from Β§2 (swap in your project id):
import pandas as pd
import requests
import streamlit as st
BASE = "https://mockbird.mockbird.workers.dev/m/YOUR_PROJECT_ID"
st.title("Team dashboard")
rows = requests.get(f"{BASE}/people", params={"sortBy": "salary", "order": "desc"},
timeout=10).json()
st.dataframe(pd.DataFrame(rows))
st.subheader("Add someone")
with st.form("add_person", clear_on_submit=True):
name = st.text_input("Name")
role = st.text_input("Role")
salary = st.number_input("Salary", min_value=0, step=1000)
remote = st.checkbox("Remote")
if st.form_submit_button("Add"):
r = requests.post(f"{BASE}/people",
json={"name": name, "role": role,
"salary": salary, "remote": remote},
timeout=10)
r.raise_for_status()
st.rerun() # re-fetch β the new row is really there
We ran exactly this app through AppTest: filled the form, submitted, and the table re-rendered with 4 rows β then fetched /people/4 with plain requests from outside Streamlit and got Annie back. It's a real record on a real URL, visible to anyone you share the link with.
On localhost every fetch resolves in milliseconds, so nobody ever sees the spinner until the app is deployed and the API is slow. Ask the mock to be slow β and cache the result the way you would in production:
import requests
import streamlit as st
BASE = "https://mockbird.mockbird.workers.dev/m/demo"
@st.cache_data(ttl=60)
def load_products(delay_ms=0):
r = requests.get(f"{BASE}/products",
params={"mock_delay": delay_ms}, timeout=15)
r.raise_for_status()
return r.json()
with st.spinner("Loading products⦠(the API is taking 3 s on purpose)"):
rows = load_products(delay_ms=3000)
st.success(f"{len(rows)} products loaded")
Measured when we ran it: the first run took 4.0 s (a real 3 s server-side delay plus network β the spinner is genuinely on screen), the second run 0.12 s, because st.cache_data served it without touching the network. That's the whole loading story β spinner, slow API, cache recovery β exercised before a single user sees it.
import requests
import streamlit as st
BASE = "https://mockbird.mockbird.workers.dev/m/demo"
FORCE_STATUS = 500 # flip to 0 to load normally
params = {"limit": 100}
if FORCE_STATUS:
params["mock_status"] = FORCE_STATUS
r = requests.get(f"{BASE}/products", params=params, timeout=10)
if r.ok:
st.dataframe(r.json())
else:
st.error(f"The products API answered {r.status_code}. Showing nothing "
"is better than showing something wrong.")
st.button("Retry") # any click reruns the script top to bottom
?mock_status=500 makes the same endpoint answer a real HTTP 500 (any code 100β599 works: 401 for expired sessions, 429 for rate limits, 503 for maintenance pages). Your st.error branch, designed on purpose, with a Retry button β clicking any button reruns a Streamlit script, so retry is free.
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 requests
import streamlit as st
BASE = "https://mockbird.mockbird.workers.dev/m/demo"
@st.cache_data(ttl=30)
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": "streamlit-retry-demo"},
timeout=10)
if r.ok:
return attempt, r.json()
r.raise_for_status()
attempts, rows = load_with_retry()
st.info(f"Loaded {len(rows)} products on attempt {attempts} of 3")
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.
import requests
import streamlit as st
BASE = "https://mockbird.mockbird.workers.dev/m/demo"
rows = requests.get(f"{BASE}/products",
params={"mock_snapshot": "empty"}, timeout=10).json()
if rows:
st.dataframe(rows)
else:
st.info("No products yet β this is your empty state. Design it on purpose.")
?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. On your own project you can save any number of named scenarios and pin each demo tab, teammate, or test to a different one, without touching live data.
Streamlit's built-in AppTest runs your script headlessly and lets you assert on rendered elements. One thing it does not do is mock your HTTP calls β we proved it: an AppTest run of an app fetching with ?mock_delay=2000 took 3.1 s of wall clock. Your tests hit the real network, so point them at data that can't drift:
# test_dashboard.py β pip install pytest β pytest test_dashboard.py
from streamlit.testing.v1 import AppTest
def test_dashboard_renders_products():
at = AppTest.from_file("apps/dashboard.py", default_timeout=15)
at.run()
assert not at.exception
assert at.metric[0].value == "30" # demo ships 30 products
assert len(at.dataframe[0].value) == 30
def test_empty_state_branch():
at = AppTest.from_file("apps/empty.py", default_timeout=15)
at.run()
assert not at.exception
assert "empty state" in at.info[0].value
def test_error_branch():
at = AppTest.from_file("apps/errors.py", default_timeout=15)
at.run()
assert not at.exception
assert "500" in at.error[0].value
assert at.button[0].label == "Retry"
3 passed in 5.24s when we ran it against production. The first test can assert == "30" only because the demo reseeds to exactly 30 products β on your own project, pin tests to a named snapshot so a colleague's writes can't flake your CI. (Note default_timeout: AppTest's default is 3 s, which a deliberately slow mock will exceed.)
| Hard-coded CSV / fixture files | responses / requests-mock | Mockbird | |
|---|---|---|---|
| What it is | pd.read_csv("fixture.csv") in the app | Patches requests in-process (tests only) | Hosted mock API |
| App code is production-shaped (fetches a URL) | β rewrite before ship | β in tests, β for streamlit run | β swap one base URL to ship |
| Works offline / zero latency | β | β | β (real network) |
Writes persist (forms, st.data_editor save-backs) | β | β | β |
| Loading / error / retry states are real | β | Simulated at best | β mock_delay/mock_status/mock_seq |
| Same data visible to teammates, CI, the browser | Files drift apart | β one process | β one https URL |
| Free tier | free | free (library) | free: 20 projects, 10k req/project/day |
Use all three where they're strongest: fixtures for offline sketching, responses for millisecond unit tests of helper functions, and a hosted URL the moment the app needs to look and fail like production. More on the Python testing side (retry adapters vs chaos, httpx, pytest fixtures): mock APIs for Python. Building dashboards in Grafana instead? The Grafana / Infinity data source version of this guide. Building an ML demo in Gradio? The Gradio version β including a mocked POST /predict with GPU-ish latency. Working in R/Shiny instead? The R version β httr2, data.frames both directions, and testServer-verified Shiny. Doing the analysis in a bare notebook first? The pandas/Jupyter version β the read_json 403 trap in full, pagination loops, and df.to_csv round-tripped into your own typed API.
Mockbird follows plain REST conventions, so the swap to the real backend is one line β put the base URL in st.secrets and read BASE = st.secrets["api_base"]. Until then, hand your platform 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 20, 2026 β through Streamlit 1.64's own AppTest harness (30 assertions: rendered metrics/tables/branches, measured spinner + cache timings, deterministic mock_seq headers, the form write read back from outside Streamlit) plus a real streamlit run render check in a real browser. 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 in Β§3.