Module 3: Metrics Latency Throughput Errors
8. Mini-project: measure Reservo's metrics
Overview
The moment has come to put the whole module in your hands. In this mini-project you bring up the Reservo API —with the declared slow endpoint—, run a load generator over it, and produce a complete metrics report: p50, p95, and p99 latency, RPS, and error rate, all actually measured. Then you write the equivalent k6 summary block as content, and interpret in writing the question that runs through the whole module: why the p95 matters to the user more than the average. It's not an exercise in repeating definitions; it's the real work of whoever interprets a load test, done by you from start to finish.
Connection to the module: this project exercises the seven lessons at once. You bring up the API (module 1's infrastructure), launch VUs (module 2), measure latency and compute percentiles (lessons 2-3), report throughput (lesson 4) and error rate (lesson 5), write and read a k6 summary (lesson 6), and interpret average vs p95 (lesson 7). It's the module's close and the prelude to module 4, where instead of a fixed load you'll start to vary the load over time (ramps, spikes) and observe how these same metrics move.
What you'll deliver
Your deliverable has four pieces:
- The Reservo API (
server.py) running, with the slow endpoint/quote_slowdeclared. - The load generator (
loadgen.py) that hits an endpoint, measures each latency, and computes p50/p95/p99, RPS, and % error. - The real output of a run over
/quote_slow(measured metrics) and one over/quote_flaky(for the error rate). - A written interpretation (a paragraph) that answers: for the Reservo user, why does the p95 say more than the average? With your own numbers.
Step 1: the Reservo API (with the slow endpoint declared)
This is the canonical Reservo server, identical to the whole guide's, plus the two lab endpoints this module declares (/quote_slow and /quote_flaky). Save it as server.py. Notice it's served on port 0: the operating system assigns a free one and the server prints it on its first line, to avoid colliding with anything.
# server.py — Reservo API (canonical) + module-3 declared endpoints
import json, random, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Hourly rates in integer CENTS (never float for money).
HOURLY_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
def price_cents(room, tier, hours):
"""Price in cents: rate * hours, with integer 20% discount if pro."""
base = HOURLY_CENTS[room] * hours
if tier == "pro":
return base * 80 // 100 # integer discount, no decimals
return base
class ReservoHandler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass # silence: don't pollute the test output
def _send(self, status, payload):
body = json.dumps(payload).encode()
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 _read_json(self):
length = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(length) or b"{}")
def do_GET(self):
if self.path == "/rooms":
rooms = [{"room": r, "hourly_cents": c} for r, c in HOURLY_CENTS.items()]
self._send(200, {"rooms": rooms})
else:
self._send(404, {"error": "not_found"})
def do_POST(self):
body = self._read_json()
room, tier, hours = body.get("room"), body.get("tier"), body.get("hours")
if self.path == "/quote":
self._send(200, {"price_cents": price_cents(room, tier, hours)})
elif self.path == "/quote_slow":
# DECLARED: simulates a slow dependency with a long TAIL.
if random.random() < 0.10:
time.sleep(random.uniform(0.12, 0.25)) # the tail
else:
time.sleep(random.uniform(0.004, 0.012)) # the common case
self._send(200, {"price_cents": price_cents(room, tier, hours)})
elif self.path == "/quote_flaky":
# DECLARED: responds fast, but ~10% fails with 500.
if random.random() < 0.10:
self._send(500, {"error": "upstream_unavailable"})
else:
self._send(200, {"price_cents": price_cents(room, tier, hours)})
elif self.path == "/book":
self._send(200, {
"booking_id": f"bk_{room}_{tier}_{hours}",
"price_cents": price_cents(room, tier, hours),
"confirmed": True,
})
else:
self._send(404, {"error": "not_found"})
class ReservoServer(ThreadingHTTPServer):
daemon_threads = True
request_queue_size = 256 # large backlog: the queue is the endpoint's, not the socket's
if __name__ == "__main__":
server = ReservoServer(("127.0.0.1", 0), ReservoHandler) # port 0: the OS assigns
print(server.server_address[1], flush=True) # 1st line: the port
server.serve_forever()
Before measuring, verify the anchors —that the API is still the usual one— by starting it and hitting it with curl. This is the real output:
Focus/basic/3h -> {"price_cents": 7500}
Focus/pro/3h -> {"price_cents": 6000}
If you see 7500 and 6000, your Reservo is the canonical one and you can measure with confidence.
Step 2: the load generator
This is loadgen.py, the generator that brings together everything in the module. It receives the port, the endpoint, the total number of requests, and the concurrency; measures each latency client side, counts the errors, and at the end computes and prints the complete report with statistics.
# loadgen.py — generates load and reports REAL p50/p95/p99, RPS, and % error
import json, statistics, sys, time, urllib.request
from concurrent.futures import ThreadPoolExecutor
PORT = int(sys.argv[1])
PATH = sys.argv[2] if len(sys.argv) > 2 else "/quote"
TOTAL = int(sys.argv[3]) if len(sys.argv) > 3 else 2000
CONCURRENCY = int(sys.argv[4]) if len(sys.argv) > 4 else 50
URL = f"http://127.0.0.1:{PORT}{PATH}"
PAYLOAD = json.dumps({"room": "Focus", "tier": "basic", "hours": 3}).encode()
def one_request():
"""One request. Returns (latency_ms, ok). Timed CLIENT side."""
start = time.perf_counter()
try:
req = urllib.request.Request(URL, data=PAYLOAD,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
resp.read()
ok = (resp.status == 200)
except Exception:
ok = False # timeout, dropped connection, 500... all is failure
return (time.perf_counter() - start) * 1000, ok
def pct(data, p):
"""Percentile p (1-99) with statistics.quantiles, inclusive method."""
return statistics.quantiles(data, n=100, method="inclusive")[p - 1]
def main():
latencies, errors = [], 0
wall_start = time.perf_counter()
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
for latency_ms, ok in pool.map(lambda _: one_request(), range(TOTAL)):
latencies.append(latency_ms)
if not ok:
errors += 1
wall = time.perf_counter() - wall_start
latencies.sort()
print(f"endpoint {PATH}")
print(f"total requests {TOTAL}")
print(f"concurrency {CONCURRENCY} clients")
print(f"throughput RPS {TOTAL / wall:8.1f} req/s")
print(f"errors {errors} ({errors / TOTAL * 100:.2f}%)")
print("-- latency (ms), client side --")
print(f"avg (average) {statistics.fmean(latencies):8.2f}")
print(f"p50 {pct(latencies, 50):8.2f}")
print(f"p95 {pct(latencies, 95):8.2f}")
print(f"p99 {pct(latencies, 99):8.2f}")
if __name__ == "__main__":
main()
Step 3: measure (the real output)
Start the server, capture the port it prints, and run the generator. In one terminal:
# start the API; its first line is the port assigned by the OS
python3 server.py
# -> (prints something like 55870)
# in another terminal, with that port, run the generator over the slow endpoint
python3 loadgen.py 55870 /quote_slow 2000 50
What to expect on /quote_slow. Since the endpoint has a long tail, you'll see a moderate average but a p95 and p99 well above it —the tail's signature—. This is the real output:
endpoint /quote_slow
total requests 2000
concurrency 50 clients
throughput RPS 1517.6 req/s
errors 0 (0.00%)
-- latency (ms), client side --
avg (average) 28.64
p50 12.18
p95 182.81
p99 240.31
Now measure the error rate over the flaky endpoint:
python3 loadgen.py 55870 /quote_flaky 2000 30
What to expect on /quote_flaky. Excellent latency, but ~10% error. Real output:
endpoint /quote_flaky
total requests 2000
concurrency 30 clients
throughput RPS 4761.1 req/s
errors 200 (10.00%)
-- latency (ms), client side --
avg (average) 6.25
p50 5.45
p95 10.06
p99 27.71
With these two runs you have all the material for the report: from /quote_slow, the tailed-latency story (p95 = 182.81 ms, well above the 28.64 average); from /quote_flaky, the error-rate story (10% with a latency that, alone, would look healthy). Note that your numbers won't be identical to these down to the millisecond cent —a load test varies a bit between runs, depending on the machine and its state—, but the shape will be the same: on /quote_slow, p95 ≫ average; on /quote_flaky, ~10% error with low latency.
Step 4: the equivalent k6 summary (content)
As part of the deliverable, write what the k6 run summary would look like for an equivalent test over /quote_slow. Remember: k6 isn't installed; this is labeled content, faithful to the official format, not run. Map your measured metrics to the k6 lines:
# CONTENT (this is how `k6 run` looks; k6 is not installed)
█ TOTAL RESULTS
HTTP
http_req_duration..................: avg=28.6ms min=4.9ms med=12.2ms max=257ms p(90)=38.6ms p(95)=182.8ms
http_req_failed....................: 0.00% 0 out of 2000
http_reqs..........................: 2000 1517.6/s
EXECUTION
iterations.........................: 2000 1517.6/s
vus................................: 50 min=50 max=50
vus_max............................: 50 min=50 max=50
Notice the bridge: your Python p95 = 182.81 is k6's p(95)=182.8ms column; your errors 0 (0.00%) is http_req_failed 0.00%; your RPS 1517.6 is the rate of http_reqs 1517.6/s; your concurrency 50 is vus_max 50. It's not magic: it's the same thing you measured, in k6's format.
Step 5: the written interpretation
Close the deliverable with a paragraph that answers, with your numbers, the module's question: for the Reservo user, why does the p95 say more than the average? An example of a well-done answer:
On
/quote_slow, the average latency was 28.64 ms, but the p95 was 182.81 ms —more than six times greater—. The average describes a user who almost doesn't exist: half the requests (p50 = 12.18 ms) were faster than half of that average, while 1 in every 20 users (the p95) waited 183 ms or more. If I promised the business "the quote takes 29 ms on average," I'd be hiding that a real fraction of the customers lives a wait six times worse. The p95 makes that tail user visible —the one who gets frustrated and maybe abandons—, and that's why it's the metric an SLO is written over (p(95) < 200 ms), not the average. Also, the/quote_flakyrun reminds us that latency isn't enough: there the p95 was excellent (10 ms) but 10% of the requests failed, so "fast" didn't mean "good." The honest verdict uses the three instruments: latency p95, RPS, and error rate, read together.
Self-assessment rubric
Check each point. If you fail one, go back to the indicated lesson.
| # | Criterion | Do you meet it? | Lesson |
|---|---|---|---|
| 1 | The API starts on port 0 and returns the anchors (7500, 6000). | ☐ | 1 |
| 2 | The generator measures each latency client side and counts errors (ok = status == 200). | ☐ | 2, 5 |
| 3 | You compute p50/p95/p99 with statistics.quantiles(..., n=100, method="inclusive"). | ☐ | 3 |
| 4 | You report the RPS as total / wall_time. | ☐ | 4 |
| 5 | You report the error rate as errors / total and read it first. | ☐ | 5 |
| 6 | On /quote_slow, your p95 is much larger than your average (the tail). | ☐ | 7 |
| 7 | On /quote_flaky, you have ~10% error with low latency. | ☐ | 5 |
| 8 | You write the k6 summary as labeled content, not as something executed. | ☐ | 6 |
| 9 | Your interpretation explains, with your numbers, why the p95 > average matters. | ☐ | 7 |
| 10 | Your verdict reads the three instruments together (latency, throughput, error). | ☐ | 1, 5 |
Common mistakes
Reporting only /quote_slow and forgetting the error rate. What happens: the deliverable has a gorgeous p95 tail analysis, but no run with errors, so the third instrument goes unexercised. Why it happens: the tail lesson is the flashy one and steals the attention. How to detect it: if your report has no run with % error > 0, you're missing half the verdict. How to fix it: include the /quote_flaky run and read it in the correct order (error first).
Presenting the k6 summary as if you had run it. What happens: the deliverable pastes a k6 block with no label, implying k6 run was run. Why it happens: the environment rule is forgotten. How to detect it: if your k6 block doesn't say "content, not run," it breaks the guide's honesty. How to fix it: always label it. What you actually ran is the Python generator; the k6 summary is content faithful to the format.
Interpreting with adjectives instead of numbers. What happens: the interpretation says "the p95 is important because it shows the real experience," without a single number. Why it happens: it's easier to repeat the lesson than to apply it. How to detect it: if your paragraph doesn't cite your p95, your average, and your error rate, it's generic. How to fix it: anchor each statement in a measured number of yours ("my average was 28.64 but my p95 was 182.81, six times more").
Summary and next step
In this mini-project you measured Reservo's metrics with your own hands, from start to finish: you brought up the canonical API with its declared slow endpoint, ran a load generator that computes real p50/p95/p99, RPS, and % error, wrote the equivalent k6 summary as labeled content, and interpreted with your own numbers why the p95 tells the user more than the average. You saw, measured by you, the module's two truths: on /quote_slow, a p95 (182.81 ms) six times larger than the average (28.64) —the tail the average hides—; and on /quote_flaky, 10% error with excellent latency —the reminder that "fast" isn't "good"—.
With this you close the guide's interpretive heart. You now know what the three metric families are, how they're computed, how they're read in the k6 summary, and —most important— how to draw an honest verdict by reading them together and in the correct order. Before continuing you should be able to do the whole project without looking at the lessons: bring up, measure, report, and interpret.
What comes next, in module 4, is to stop measuring a fixed load and start to model how the load varies over time: the load profiles (stages, ramp-up and ramp-down, spikes) and k6's executors. The question changes from "what metrics does this load produce?" to "how do these metrics move when the load rises, holds, and falls?" —and to answer it you need exactly the instruments you mastered here—.
Resources
- k6 — Built-in metrics (reference) — the catalog of the metrics you report in the project (
http_req_duration,http_req_failed,http_reqs) and their exact names for the content summary. statistics.quantiles— Python documentation — the function the generator computes the report's percentiles with.http.server— Python documentation — the standard-library module the Reservo API runs with, without installing anything.- Module 4 of this guide — Load profiles and stages — the next step: model how the load rises, holds, and falls over time, and see how these metrics move.