Skip to main content
API Testing · lesson 9 of 11
API Testing advanced 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

api-testing python pagination rate-limiting pytest

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.

Frequently Asked Questions

Why did looping over a hardcoded page range miss items, when the API itself was correct?
The API was never wrong — it faithfully returned whatever page was requested. The test assumed there were exactly 2 pages of 10 and never asked the API how many there actually were. has_next exists precisely so a client doesn't have to guess the page count; ignoring it is the bug, not the pagination logic.
What's the difference between offset pagination (page/per_page) and cursor pagination?
Offset pagination computes a position from page and per_page, which is simple but can skip or repeat items if rows are inserted or deleted between requests. Cursor pagination instead returns an opaque token pointing at the last item seen, so the next page picks up exactly where the last one ended regardless of what changed — many high-traffic APIs use cursors for this reason.
Why does the rate limiter's window reset based on time instead of a fixed count?
A real API's rate limit resets on a rolling or fixed time window ("5 requests per second"), not after a certain number of calls forever — otherwise a client would be permanently locked out after one burst. Resetting request_count once now - window_start exceeds the window models that correctly, and it's also what makes the Retry-After-respecting test able to recover instead of failing forever.
What should a client actually do with a 429 and a Retry-After header?
Read the header and wait at least that long before retrying — not retry immediately (which just extends the rate limit) and not give up permanently (Retry-After is a promise the request will likely succeed after that wait). urllib3's Retry class understands Retry-After natively when 429 is in status_forcelist, so this can often be handled the same way as the 503 retries from the previous lesson.
Is it worth testing rate limiting at all if my code never gets close to the limit in production?
Yes, for exactly the case where it does — a batch job, a retry storm, or a traffic spike is when a client actually hits a 429 for the first time, and that's a bad moment to discover the error handling was never tested. A test doesn't need to reproduce production traffic to prove the client behaves correctly under a 429.