API Testing · lesson 9 of 11
Testing Pagination and Rate Limits
The most common pagination bug isn't a broken API — it's a test that hardcodes the number of pages. Then: testing a 429 response and honoring Retry-After.
By SkillByExample · Published
A paginated list and a rate-limited endpoint fail in the same subtle way: the API is doing exactly what it’s supposed to, and the bug is in a client that didn’t read what it returned.
A paginated endpoint
# local_api.py
import json
import time
from http.server import BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
class PaginatedAPIHandler(BaseHTTPRequestHandler):
def _send_json(self, status, payload, headers=None):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
for key, value in (headers or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)
def do_GET(self):
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
if parsed.path == "/books":
self._handle_list_books(query)
return
if parsed.path == "/limited/books":
self._handle_rate_limited()
return
self._send_json(404, {"error": "not found"})
def _handle_list_books(self, query):
page = int(query.get("page", ["1"])[0])
per_page = int(query.get("per_page", ["10"])[0])
books = self.server.books
start = (page - 1) * per_page
end = start + per_page
page_items = books[start:end]
self._send_json(200, {
"items": page_items,
"page": page,
"per_page": per_page,
"total": len(books),
"has_next": end < len(books),
})
def log_message(self, format, *args):
pass
The fixture seeds 25 books, so per_page=10 produces three pages: 10, 10, 5.
import requests
def test_first_page_returns_ten_items_and_has_next(local_api_url):
response = requests.get(f"{local_api_url}/books", params={"page": 1, "per_page": 10})
data = response.json()
assert len(data["items"]) == 10
assert data["has_next"] is True
def test_last_page_has_no_next(local_api_url):
response = requests.get(f"{local_api_url}/books", params={"page": 3, "per_page": 10})
data = response.json()
assert len(data["items"]) == 5
assert data["has_next"] is False
test_pagination.py::test_first_page_returns_ten_items_and_has_next PASSED [ 50%]
test_pagination.py::test_last_page_has_no_next PASSED [100%]
============================== 2 passed in 1.12s ==============================
The bug: a hardcoded page count
def test_hardcoded_page_count_misses_items(local_api_url):
all_items = []
for page in [1, 2]:
response = requests.get(f"{local_api_url}/books", params={"page": page, "per_page": 10})
all_items.extend(response.json()["items"])
assert len(all_items) == 25
def test_hardcoded_page_count_misses_items(local_api_url):
all_items = []
for page in [1, 2]:
response = requests.get(f"{local_api_url}/books", params={"page": page, "per_page": 10})
all_items.extend(response.json()["items"])
> assert len(all_items) == 25
E AssertionError: assert 20 == 25
E + where 20 = len([{'id': 1, 'title': 'Book 1'}, {'id': 2, 'title': 'Book 2'}, ...])
The test guessed there were 2 pages. There are 3. Nothing about the API was wrong — page=1
and page=2 each returned exactly what was asked for — the test simply never asked the API
how many pages actually existed.
The fix: follow has_next
def test_looping_until_has_next_is_false_collects_everything(local_api_url):
all_items = []
page = 1
while True:
response = requests.get(f"{local_api_url}/books", params={"page": page, "per_page": 10})
data = response.json()
all_items.extend(data["items"])
if not data["has_next"]:
break
page += 1
assert len(all_items) == 25
assert page == 3
test_pagination_fixed.py::test_looping_until_has_next_is_false_collects_everything PASSED [100%]
============================== 1 passed in 0.58s ==============================
The loop stops when the API says to stop, not when the test’s assumption runs out — this is the only version of this test that survives the dataset growing to 26 books tomorrow.
Rate limiting
Add a route that allows a fixed number of requests per rolling window, then rejects the rest
with 429 and a Retry-After header:
def _handle_rate_limited(self):
now = time.monotonic()
if now - self.server.window_start > 1:
self.server.window_start = now
self.server.request_count = 0
self.server.request_count += 1
if self.server.request_count > self.server.limit:
self._send_json(429, {"error": "rate limited"}, headers={"Retry-After": "1"})
return
self._send_json(200, {"ok": True})
import time
import requests
def test_requests_within_limit_succeed(local_api_url):
for _ in range(3):
response = requests.get(f"{local_api_url}/limited/books")
assert response.status_code == 200
def test_exceeding_the_limit_returns_429_with_retry_after(local_api_url):
for _ in range(3):
requests.get(f"{local_api_url}/limited/books")
response = requests.get(f"{local_api_url}/limited/books")
assert response.status_code == 429
assert response.headers["Retry-After"] == "1"
def test_waiting_out_retry_after_lets_the_request_through(local_api_url):
for _ in range(3):
requests.get(f"{local_api_url}/limited/books")
limited = requests.get(f"{local_api_url}/limited/books")
retry_after = int(limited.headers["Retry-After"])
time.sleep(retry_after)
recovered = requests.get(f"{local_api_url}/limited/books")
assert recovered.status_code == 200
test_rate_limit.py::test_requests_within_limit_succeed PASSED [ 33%]
test_rate_limit.py::test_exceeding_the_limit_returns_429_with_retry_after PASSED [ 66%]
test_rate_limit.py::test_waiting_out_retry_after_lets_the_request_through PASSED [100%]
============================== 3 passed in 2.63s ==============================
Retry-After isn’t decorative — the third test reads it and waits exactly that long rather
than a hardcoded guess, which is what a well-behaved retrying client should do against a real
API’s rate limiter.
Practice
1. Request a per_page large enough to return every book in one page.
def test_per_page_of_25_returns_everything_in_one_page(local_api_url):
response = requests.get(f"{local_api_url}/books", params={"page": 1, "per_page": 25})
data = response.json()
assert len(data["items"]) == 25
assert data["has_next"] is False
test_practice.py::test_per_page_of_25_returns_everything_in_one_page PASSED [ 33%]
per_page isn’t capped in this implementation — a real API almost always enforces a maximum,
which would be worth its own test against a real one.
2. Request a page number past the last one and check what comes back.
def test_page_past_the_end_returns_empty_items(local_api_url):
response = requests.get(f"{local_api_url}/books", params={"page": 4, "per_page": 10})
data = response.json()
assert data["items"] == []
assert data["has_next"] is False
test_practice.py::test_page_past_the_end_returns_empty_items PASSED [ 66%]
Python slicing past the end of a list returns [] rather than raising — the handler never had
to special-case this, but it’s worth asserting the behavior explicitly rather than assuming
the language’s slicing rules line up with the API’s contract.
3. Confirm the documented defaults apply when no query params are sent at all.
def test_no_query_params_uses_documented_defaults(local_api_url):
response = requests.get(f"{local_api_url}/books")
data = response.json()
assert data["page"] == 1
assert data["per_page"] == 10
assert len(data["items"]) == 10
test_practice.py::test_no_query_params_uses_documented_defaults PASSED [100%]
Defaults are part of the contract too — a client that omits page entirely should get the
same first page as one that explicitly passes page=1.
Next: validating against a formal OpenAPI schema and running the suite in CI.