DEV Community

Sir Max
Sir Max

Posted on

How I Built an API Latency Tester in 80 Lines of Python (And What It Surprised Me About)

How I Built an API Latency Tester in 80 Lines of Python (And What It Surprised Me About)

Last week I needed to compare latency across three different API providers for the same endpoint. curl + time worked for one-off tests, but I wanted concurrency, percentiles, and side-by-side comparisons.

So I wrote a tiny CLI tool. 80 lines. Here's how it works — and what the numbers actually taught me.


The Problem

I had three endpoints returning the same JSON structure:

  • Provider A: US East, 200ms baseline
  • Provider B: Europe, claimed "sub-100ms"
  • Provider C: Asia Pacific, no SLA

I needed to know: which one is actually fastest from my region, under real concurrency?

curl -w "@curl-format.txt" gives you total time. ab (ApacheBench) gives you concurrency. Neither gives you percentile latency with concurrent connections in a single readable output.


The Tool

Here's the full thing. I called it apiping:

#!/usr/bin/env python3
"""apiping — concurrent API latency tester with percentile output"""
import asyncio, aiohttp, time, statistics, argparse, json
from dataclasses import dataclass

@dataclass
class Result:
    url: str
    latencies: list  # in milliseconds
    errors: int = 0

    @property
    def avg(self): return statistics.mean(self.latencies) if self.latencies else 0
    @property
    def p50(self): return self._percentile(50)
    @property
    def p95(self): return self._percentile(95)
    @property
    def p99(self): return self._percentile(99)
    @property
    def success_rate(self):
        total = len(self.latencies) + self.errors
        return (len(self.latencies) / total * 100) if total else 0

    def _percentile(self, p):
        if not self.latencies:
            return 0
        idx = int(len(self.latencies) * p / 100)
        return sorted(self.latencies)[min(idx, len(self.latencies)-1)]

async def ping(session, url, method="GET", headers=None, body=None):
    start = time.monotonic()
    try:
        async with session.request(method, url, headers=headers, json=body,
                                    timeout=aiohttp.ClientTimeout(total=10)) as resp:
            await resp.read()
            return (time.monotonic() - start) * 1000  # ms
    except Exception:
        return None

async def run_test(url, concurrency, count):
    async with aiohttp.ClientSession() as session:
        tasks = [ping(session, url) for _ in range(count)]
        results = []
        # Process in chunks to limit concurrency
        for i in range(0, len(tasks), concurrency):
            chunk = tasks[i:i+concurrency]
            results.extend(await asyncio.gather(*chunk))
    latencies = [r for r in results if r is not None]
    return Result(url=url, latencies=latencies,
                  errors=len(results) - len(latencies))

async def main():
    parser = argparse.ArgumentParser(description="Concurrent API latency tester")
    parser.add_argument("urls", nargs="+", help="URLs to test")
    parser.add_argument("-c", "--concurrency", type=int, default=10)
    parser.add_argument("-n", "--count", type=int, default=100)
    parser.add_argument("-H", "--header", action="append",
                        help="Headers in 'Key: Value' format")
    args = parser.parse_args()

    headers = {}
    if args.header:
        for h in args.header:
            k, v = h.split(":", 1)
            headers[k.strip()] = v.strip()

    print(f"Testing {len(args.urls)} endpoint(s), "
          f"{args.count} requests each, concurrency={args.concurrency}\n")

    results = []
    for url in args.urls:
        r = await run_test(url, args.concurrency, args.count)
        results.append(r)
        print(f"  {url}")
        print(f"    avg={r.avg:.1f}ms  p50={r.p50:.1f}ms  "
              f"p95={r.p95:.1f}ms  p99={r.p99:.1f}ms  "
              f"success={r.success_rate:.1f}%")

    # Comparison summary
    if len(results) > 1:
        print("\n--- Comparison ---")
        best = min(results, key=lambda r: r.p95)
        print(f"  Lowest p95: {best.url} ({best.p95:.1f}ms)")

if __name__ == "__main__":
    asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Usage:

pip install aiohttp
python apiping.py https://api-a.com/health https://api-b.com/health -c 20 -n 200
Enter fullscreen mode Exit fullscreen mode

That's it. No dependencies beyond aiohttp (and statistics from stdlib).


What Surprised Me

Here's what the numbers actually showed — and two things I did NOT expect:

1. Average is a liar

Provider B had 85ms average. Great, right?

p95 was 1,240ms. Fourteen times the average. One in twenty requests took over a second.

The pattern was clear: every ~30 seconds, a cold-start spike hit. Their "sub-100ms" claim was technically true for the median — which is exactly why they quoted it.

Lesson: Always ask for p95 or p99. If a provider only gives you "average latency," assume the real number is 5-10x worse.

2. Concurrency changes everything

Provider A (US East) at concurrency=1: 180ms avg, consistent.
At concurrency=50: 2,400ms avg, 8% error rate.

They had a connection pool capped at 32. Once I exceeded it, every extra request queued up behind that limit. The latency curve was flat until 32, then went vertical.

Lesson: Test at your actual production concurrency. A single-request benchmark tells you nothing about what happens under load.

Provider C (Asia Pacific) won the p95 comparison because their infrastructure was newer and over-provisioned — higher baseline latency (220ms) but almost zero variance. For my use case (batch processing), consistent 220ms beat "sometimes 80ms, sometimes 1.2s."


What I'd Add Next

  • Warm-up phase: Discard first N requests (cold TCP/TLS handshake noise)
  • Streaming support: Test SSE/WebSocket endpoints
  • Latency histogram: ASCII chart in terminal output
  • JSON output mode: For piping into jq or Grafana

The Real Takeaway

You don't need k6, locust, or artillery to get useful latency data. For API selection decisions (not full load testing), 80 lines of Python plus aiohttp gives you percentiles, concurrency, and error rates — enough to catch providers that cherry-pick their numbers.

The full code is up on GitHub if you want it. Drop a comment if you've run into similar surprises benchmarking APIs — I'm collecting stories.

Top comments (0)