Module 1: Why Load And Performance Testing

6. The Reservo API and seeing it respond

Overview

You can't measure the load of something that doesn't exist. Before launching traffic we need a target: a real system that responds to HTTP requests, to hit it and measure. In this lesson we build that whole target —the canonical Reservo API— with Python's standard library, without installing a single dependency, and we see it respond for real. It's a tiny HTTP server with three endpoints: GET /rooms (lists the rooms and their rate), POST /quote (receives {room, tier, hours} and returns {price_cents}), and POST /book (confirms and returns {booking_id, price_cents, confirmed}). All money is handled in integer cents, and the pro tier's discount is applied with integer division (* 80 // 100), never with float. By the end you'll have the server that the Python load generator (lesson 7) and the k6 script (content) will hammer all guide long, and you'll have seen it return the anchor numbers 7500 and 6000 with actually executed output.

Connection to the module: this lesson lands the API we introduced in the abstract in lesson 1. It's pure executable Python —the server does run and its output is cited—, in contrast to k6, which is content. Here we build and verify the target; in lesson 7 we launch the first concurrent traffic at it and measure. The boundary with the Playwright guide becomes sharp here: over there the subject under test is a page (reservo.html, tested through the browser); here it's the API (the HTTP server that responds with JSON, tested by load). Same business (Reservo), different subject, different question.

The taco stand you need before measuring the line

To measure how many people a taco stand can handle, first the stand has to exist. You can't time the line of a closed spot. Before bringing the hundred test customers, you set up the stand: the griddle, the counter, the cash register, the price list stuck on the wall. A minimal but complete stand: it doesn't need chairs, or a dessert menu, or a terrace —just enough to take an order, charge, and hand it over—. With that you can already measure how many orders per minute it dispatches before a queue forms.

The Reservo API is that minimal stand. It has no database, no login, no admin panel —none of that is needed to measure load—. It has exactly what's just enough: it receives a request, computes a price, responds. The price list stuck on the wall is the HOURLY_CENTS dictionary; the cash register that applies discounts is the price_cents function; the counter that takes orders is the three endpoints. We set up the stand in this lesson so we can measure its line in the next.

The design decisions (and why)

Before the code, three decisions worth understanding, because they're the ones that make Reservo a good load target.

We use http.server from the standard library. Python ships an HTTP server built into the http.server module. It's not a production framework (Flask, FastAPI); it's the minimum for responding to HTTP requests. We choose it precisely for that: zero dependencies, zero pip install, zero configuration. All your attention goes to the load technique, not to setting up infrastructure. The technique you practice against this server is identical to the one you'd apply against a production API; only the target changes.

Money goes in integer cents, always. Rates are stored as 2500, 4000, 8000 —cents, type int—, never as 25.00 in floating point. The reason is a hard rule of all money-touching software: floats don't represent decimal amounts exactly (0.1 + 0.2 doesn't equal 0.3 in floating point), and with money that produces one-cent errors that accumulate. Storing integer cents, all operations are exact. The pro discount is applied with integer division: price * 80 // 100, which truncates down to the cent, no decimals. That's why 7500 * 80 // 100 gives exactly 6000.

The server listens on port 0. When a server asks for port 0, the operating system assigns it any free port. This is key in an environment where several servers may run at once (as when several agents or processes work in parallel): instead of fighting over a fixed port (8000, say) and colliding, each server takes a free one. The server writes the chosen port to a PORT file so the client can read it. On your machine you can use a fixed port if you prefer; port 0 is a good habit to avoid collisions.

The server code

Here's the complete Reservo server. The identifiers and fields are in English (price_cents, room, tier, hours) because that's the tech market; the comments in English too (in this translated version). Read it calmly; below we break it down piece by piece.

"""Local Reservo API — the guide's canonical load target.

A minimal HTTP server (stdlib http.server) with three endpoints:
  GET  /rooms   -> list of rooms and their hourly rate (cents)
  POST /quote   -> {room, tier, hours}   -> {price_cents}
  POST /book    -> {room, tier, hours}   -> {booking_id, price_cents, confirmed}

Money ALWAYS in integer cents (int). Pro discount = 20% INTEGER.
"""
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

# Hourly rate of each room, IN CENTS (int) — never float for money.
HOURLY_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}


def price_cents(room, tier, hours):
    """Price in cents: rate * hours, 20% INTEGER discount for pro."""
    price = HOURLY_CENTS[room] * hours
    if tier == "pro":
        price = price * 80 // 100  # 80% of the price, integer division (no float)
    return price


class ReservoServer(ThreadingHTTPServer):
    # Larger pending-connection queue (default is 5): under concurrent
    # load, a short queue makes the OS reject connections ("connection
    # reset"). We raise it so the target withstands dozens of clients at once.
    request_queue_size = 256
    daemon_threads = True


class ReservoHandler(BaseHTTPRequestHandler):
    # Silence the per-request log (avoids noise under load).
    def log_message(self, *args):
        pass

    def _send_json(self, status, payload):
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        if self.path == "/rooms":
            rooms = [{"room": r, "hourly_cents": c} for r, c in HOURLY_CENTS.items()]
            self._send_json(200, {"rooms": rooms})
        else:
            self._send_json(404, {"error": "not_found"})

    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        raw = self.rfile.read(length) if length else b"{}"
        try:
            data = json.loads(raw or b"{}")
        except json.JSONDecodeError:
            self._send_json(400, {"error": "bad_json"})
            return

        room = data.get("room")
        tier = data.get("tier")
        hours = data.get("hours")

        # Validation: known room, valid tier, hours integer >= 1.
        if room not in HOURLY_CENTS or tier not in ("basic", "pro") \
                or not isinstance(hours, int) or hours < 1:
            self._send_json(400, {"error": "bad_request"})
            return

        cents = price_cents(room, tier, hours)

        if self.path == "/quote":
            self._send_json(200, {"price_cents": cents})
        elif self.path == "/book":
            booking_id = f"bk_{room}_{tier}_{hours}"
            self._send_json(200, {"booking_id": booking_id,
                                  "price_cents": cents, "confirmed": True})
        else:
            self._send_json(404, {"error": "not_found"})


def main():
    # Port 0 = the OS assigns a free port (won't collide with other processes).
    server = ReservoServer(("127.0.0.1", 0), ReservoHandler)
    host, port = server.server_address
    # Write the chosen port so the client can read it.
    with open("PORT", "w") as f:
        f.write(str(port))
    print(f"Reservo listening on http://{host}:{port}")
    server.serve_forever()


if __name__ == "__main__":
    main()

Taking it apart

  • HOURLY_CENTS and price_cents. The heart of the business logic. The dictionary is the price list (Focus 2500, Studio 4000, Boardroom 8000, in cents); the function multiplies rate by hours and applies the 20% pro discount with integer division. This is the same calculation the Playwright guide does in JavaScript on the page; here it lives in Python, on the server. The anchor numbers come from here.
  • ReservoServer with request_queue_size = 256. A detail we learned by measuring: http.server's server queues only 5 pending connections by default. Under concurrent load (dozens of clients at once, as in lesson 7), that short queue overflows and the operating system starts rejecting connections with a "connection reset." We raise the queue to 256 so the target withstands the load we're going to throw at it. ThreadingHTTPServer (which it inherits from) serves each request in its own thread, so several requests are processed in parallel —exactly what we need for the concurrency to be real—.
  • do_GET and do_POST. The methods that respond to each type of request. do_GET handles /rooms. do_POST handles /quote and /book: it reads the JSON body, validates (known room, valid tier, hours integer ≥ 1 —and responds 400 if not—), computes the price, and responds. That validation matters for load: under traffic, we want bad requests to return a clean 400, not to bring down the server.
  • main and port 0. It creates the server on port 0 (the OS picks a free one), writes the real port to the PORT file, and stays serving forever with serve_forever().

Seeing it respond (actually executed)

With the server saved to a file (for example reservo_server.py), we start it in the background and read the port it chose. Everything that follows is real output, run against the server running on localhost.

What to expect — on startup, the server prints which port it ended up listening on (the exact number varies on each start, because the OS assigns it):

$ python3.14 reservo_server.py &
Reservo listening on http://127.0.0.1:51568

Now we query it with curl. First list the rooms:

What to expect — a JSON with the three rooms and their hourly rate in cents:

$ curl -s http://127.0.0.1:51568/rooms
{"rooms": [{"room": "Focus", "hourly_cents": 2500}, {"room": "Studio", "hourly_cents": 4000}, {"room": "Boardroom", "hourly_cents": 8000}]}

There are the three rates, in integer cents. Now the star endpoint, quote. First the basic tier, the anchor number 7500:

What to expect — quoting Focus/basic/3h must return exactly {"price_cents": 7500} (because 2500 × 3 = 7500):

$ curl -s -X POST http://127.0.0.1:51568/quote \
    -H 'Content-Type: application/json' \
    -d '{"room":"Focus","tier":"basic","hours":3}'
{"price_cents": 7500}

And now the pro tier, the second anchor, 6000:

What to expect — quoting Focus/pro/3h applies the integer 20% discount: 7500 × 80 // 100 = 6000:

$ curl -s -X POST http://127.0.0.1:51568/quote \
    -H 'Content-Type: application/json' \
    -d '{"room":"Focus","tier":"pro","hours":3}'
{"price_cents": 6000}

The guide's two anchor numbers —7500 and 6000— coming out of the server for real. Now book:

What to expectPOST /book confirms and returns the booking_id, the price, and confirmed: true:

$ curl -s -X POST http://127.0.0.1:51568/book \
    -H 'Content-Type: application/json' \
    -d '{"room":"Focus","tier":"basic","hours":3}'
{"booking_id": "bk_Focus_basic_3", "price_cents": 7500, "confirmed": true}

And finally, let's check that an invalid request is handled well —it matters for load, because under traffic there will be malformed requests and we don't want them to break the server—:

What to expect — a nonexistent room must return a clean 400, not a server error:

$ curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:51568/quote \
    -H 'Content-Type: application/json' \
    -d '{"room":"Nope","tier":"basic","hours":3}'
{"error": "bad_request"}
HTTP 400

The server validated, rejected the unknown room with a 400, and stayed standing. That's the target ready for load: it responds correctly to the valid, cleanly rejects the invalid, and withstands concurrent connections.

The boundary with Playwright, made concrete

Now that you have the server in front of you, the boundary with the sibling guide is crystal clear. The Playwright guide tests a web page (reservo.html) through the browser: it verifies that a user who picks Focus/basic/3h and clicks "Quote" sees $75.00 on the screen. Its subject is the UI, its question is the correctness of what the user sees. This guide tests the API (reservo_server.py) over HTTP: it launches hundreds of concurrent POST /quotes at it and measures how long it takes and how many it holds. Its subject is the backend, its question is the load. Both share the business logic (Focus 2500, pro discount 20%) and the anchor numbers (7500, 6000), but they test different layers with different questions. That's why Playwright's $75.00 and the 7500 here are the same amount: one formatted for the human eye, the other in the raw cents that travel over the API.

Common mistakes

Using float for money. What happens: someone defines the rates as 25.00 and computes with decimals; sooner or later a 59.99999999 or a lost cent appears. Why it happens: it seems "natural" to write prices with a decimal point. How to detect it: if your amounts are float, you have a rounding time bomb. How to fix it: store and compute everything in integer cents (int), and format to $XX.XX only when displaying. The discount, with integer division (* 80 // 100), so the result stays an exact integer.

Leaving the connection queue at default and seeing "connection reset" under load. What happens: the server is brought up with the default configuration and, when 20 or 50 concurrent clients are launched at it, some requests fail with "connection reset by peer." Why it happens: http.server's default request_queue_size is 5; under concurrency, the queue overflows and the OS rejects connections. (It happened to us for real preparing this guide: the first concurrent run blew up with that error until we raised the queue.) How to detect it: connection errors that appear only under concurrency, not with a single request. How to fix it: raise request_queue_size (here, to 256) and use a threaded server (ThreadingHTTPServer) to serve in parallel.

Fixing a port and colliding with another process. What happens: port 8000 is hardcoded and, if another server already uses it, the startup fails with "address already in use." Why it happens: a fixed port assumes no one else has it. How to detect it: "address already in use" errors on startup. How to fix it: use port 0 so the OS assigns a free one, and write the chosen port to a file (or print it) so the client can read it. In an environment with parallel processes, this avoids collisions.

Exercises

Exercise 1 — Predict the response. Without running anything, using HOURLY_CENTS and price_cents, say what price_cents POST /quote would return for: (a) {"room": "Boardroom", "tier": "basic", "hours": 2}; (b) {"room": "Studio", "tier": "pro", "hours": 3}; (c) {"room": "Focus", "tier": "pro", "hours": 1}.

See solution
  • (a) Boardroom = 8000/h, basic (no discount): 8000 × 2 = 16000. → {"price_cents": 16000}.
  • (b) Studio = 4000/h, 3h = 12000; pro: 12000 × 80 // 100 = 9600. → {"price_cents": 9600}.
  • (c) Focus = 2500/h, 1h = 2500; pro: 2500 × 80 // 100 = 2000. → {"price_cents": 2000}.

All integers; the pro discount never produces decimals thanks to integer division.

Exercise 2 — Why integer division? The pro discount is written price * 80 // 100. (a) What does it give for a price of 7500? (b) What would happen if you wrote price * 0.80 instead, and why is it a problem for money? (c) Why * 80 // 100 (multiply first, divide after) and not // 100 * 80?

See solution
  • (a) 7500 * 80 // 100 = 600000 // 100 = 6000. Exact, integer.
  • (b) 7500 * 0.80 would give a float (6000.0), and with other amounts would produce inexact decimals (e.g. 0.1 + 0.2 != 0.3 in floating point). For money, any float is a rounding risk; you have to stay in integers.
  • (c) Multiplying first preserves precision: 7500 * 80 = 600000, and only then // 100 = 6000. If you divided first (7500 // 100 = 75, then * 80 = 6000) it happens to match in this case, but with amounts not divisible by 100 you'd lose cents in the first division. Multiplying before dividing minimizes the truncation error.

Exercise 3 — Design a slow endpoint (preview). Later in the guide we'll want an endpoint that responds slowly on purpose, to see a high p95. Without writing the whole server, describe in two or three sentences how you'd add to this server a POST /quote_slow endpoint that does the same as /quote but takes ~50 ms to respond, and why that would be useful for a load test.

See solution

I'd add a branch in do_POST for the /quote_slow path that, before responding, makes an artificial pause —time.sleep(0.05) (50 ms)— and then returns the same {"price_cents": ...} as /quote. That simulates an endpoint with heavy work (a slow database query, for example). It would be useful for a load test because it would give us a target with high, controlled latency: measuring it we'd see a truly elevated p95, ideal for practicing how it's detected and how a threshold would make it fail. (A later module that needs it will add it and declare it; here we only sketch it.)

Summary and next step

In this lesson you built the target of the whole guide: the Reservo API, a minimal HTTP server made with Python's http.server —zero dependencies— with GET /rooms, POST /quote {room,tier,hours}{price_cents}, and POST /book{booking_id,price_cents,confirmed}. You understood its three design decisions: integer cents for money (with the pro discount by integer division * 80 // 100), an enlarged connection queue (request_queue_size = 256) to withstand concurrency without "connection reset," and port 0 to avoid colliding with other processes. And you saw it respond for real: /rooms with the rates, /quote with the anchor numbers 7500 (basic) and 6000 (pro), /book with the confirmation, and a clean 400 on an invalid input.

Before moving on you should be able to: explain why money goes in integer cents and the discount with integer division; say what problem raising request_queue_size and using ThreadingHTTPServer solves; and predict the price_cents of any quote from the rates.

What comes next is the moment we'd been building toward: launching load at it. In lesson 7 we make first contact —a minimal k6 script hitting /quote (as content) and its executable sibling, a mini load generator in Python that hits this server with N concurrent requests and measures the real latency (min/avg/max/p95)—. The taco stand is set up; now to measure the line.

Resources