Skip to content

geospatial-api/spatial-api-loadgen

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

spatial-api-loadgen

A load generator that hits your spatial API the way real map clients do.

Point a generic load tester at /v1/features?bbox=-0.1,51.5,-0.09,51.51 and you will learn exactly one thing: how fast that one query is when its result is already in cache. Real traffic is nothing like that. Real clients ask for windows whose area varies over four orders of magnitude, they cluster hard around a handful of cities, and they pan and zoom in short coherent sessions. Those three properties are what actually determine whether your spatial index helps, whether your CDN caches anything, and where your p99 comes from.

spatial-api-loadgen generates that traffic — seeded and reproducible — and reports the metrics that matter for spatial services, including latency broken down by the area of the window that was requested, which is usually the chart that tells you what to fix.


What it does

  • Generates spatially-coherent requests. Five generators — bounding boxes with a log-normal area distribution, hotspot-weighted points, random convex polygons, XYZ tiles walked as pan/zoom sessions, and feature-id replay with Zipf popularity.
  • Is deterministic. Every generator is seeded. The same seed produces identical traffic, so two runs are genuinely comparable and a regression can be re-run against the exact same requests after you change an index.
  • Corrects for coordinated omission. The open model schedules requests on an ideal timeline and measures latency from the intended send time, so a server stall shows up in the tail instead of quietly disappearing from it.
  • Measures spatial things. Cache hit ratio from CDN headers, rate-limit observations, empty-result ratio, features-returned percentiles, response sizes — and latency per bbox-area bucket.
  • Reports properly. A live terminal UI during the run, then a text summary, a machine-readable JSON document, and a single-file HTML report with inline SVG charts and no external assets.
  • Gates in CI. --fail-if p95>250ms,error_rate>1% for absolute thresholds and --compare baseline.json for regression detection, both with meaningful exit codes.

Why spatial load testing is different

Three things break the assumptions generic load generators are built on.

Query cost is a function of the query's extent, not just its shape. For an ordinary REST endpoint every request costs about the same, so a single latency distribution describes the service. For a spatial endpoint, a bbox covering a city block and a bbox covering a county run the same SQL with wildly different plans — at some window size the planner stops using the spatial index and starts sequentially scanning, and your p99 is made almost entirely of the queries past that threshold. An aggregate p99 tells you this happened; it does not tell you where. Bucketing latency by requested area does, and it converts "the API is sometimes slow" into "anything above roughly 100 km² falls off a cliff", which is an actionable statement about one index.

Cache behaviour depends on request locality, which uniform-random sampling destroys. Tile and bbox endpoints are cacheable precisely because clients re-request the same and adjacent regions. A generator that picks uniform-random z/x/y produces a near-zero hit ratio no matter how well your CDN is configured, and one that replays a single URL produces a 100% hit ratio no matter how badly it is. Neither number means anything. Walking realistic pan/zoom sessions, with traffic concentrated on Zipf-weighted hotspots, produces a hit ratio you can reason about.

Empty results flatter everything. A window sampled uniformly over a bounding region will very often land where you have no data. Those queries return in a millisecond and cache beautifully, and they will happily drag your reported p50 down by an order of magnitude while telling you nothing. This tool counts features in every GeoJSON response and reports the empty-result ratio, so you can see immediately when your scenario is measuring the ocean.

Install

Not published to a package index — clone it and install from source.

git clone https://github.com/geospatial-api/spatial-api-loadgen.git
cd spatial-api-loadgen

python -m venv .venv && source .venv/bin/activate
pip install -e .

Or with uv:

uv venv --python 3.12 .venv
uv pip install -e '.[dev]'

Requires Python 3.10+. Runtime dependencies are httpx (async HTTP — its transport abstraction is what lets the whole test suite run in-process), rich (the live UI and result tables), typer (the CLI) and PyYAML (scenario files).

Once installed, use either the console script or the module:

spatial-api-loadgen --help
python -m spatial_api_loadgen --help

Quickstart

Check what a scenario will do before sending anything:

$ spatial-api-loadgen validate scenarios/tile-cdn.yaml
ok scenarios/tile-cdn.yaml
  name        tile cdn
  base_url    http://localhost:8000
  model       open
  warmup      10s
  duration    90s across 2 stage(s)
  requests    ~28,500 planned
  endpoints   2
    - vector-tiles: GET /tiles/roads/{z}/{x}/{y}.mvt (tile, 90% of mix)
    - tile-metadata: GET /tiles/roads/metadata.json?z={z} (tile, 10% of mix)

Look at the actual requests it would send. Note the tile walk: the client lands on 14/2621/6332, re-requests it, zooms in, then pans to neighbours — this is what produces a realistic cache-hit ratio.

$ spatial-api-loadgen preview scenarios/tile-cdn.yaml -n 6
  1 GET /tiles/roads/14/2621/6332.mvt  3.737 km²
  2 GET /tiles/roads/metadata.json?z=15  0.934 km²
  3 GET /tiles/roads/14/2621/6332.mvt  3.737 km²
  4 GET /tiles/roads/15/5242/12665.mvt  0.934 km²
  5 GET /tiles/roads/15/5243/12666.mvt  0.935 km²
  6 GET /tiles/roads/15/5244/12667.mvt  0.935 km²

The bbox and point generators show the area of each window, so you can sanity-check the size distribution at a glance:

$ spatial-api-loadgen preview scenarios/feature-search.yaml -n 6
  1 GET /v1/features/nearest?lon=-0.097539&lat=51.562374&radius=923.0&limit=25  2.677 km²
  2 GET /v1/features/nearest?lon=-0.111226&lat=51.505929&radius=1039.7&limit=25  3.396 km²
  3 GET /v1/features?bbox=-0.097231,51.438652,-0.055826,51.476153&limit=200  11.990 km²
  4 GET /v1/features/nearest?lon=-0.065398&lat=51.468701&radius=592.5&limit=25  1.103 km²
  5 GET /v1/features?bbox=-0.265939,51.473296,-0.140799,51.53116&limit=200  55.858 km²
  6 GET /v1/features/f-1004

Then run it. This is a real 15-second run against a local service whose query cost rises with the requested area past about 50 km² — a deliberately typical "the index stops helping" shape:

$ spatial-api-loadgen run demo.yaml --json run1.json --html report.html \
      --fail-if 'p95>250ms,error_rate>2%'
wrote run1.json
wrote report.html
╭─────────────── run ───────────────╮
│   scenario  feature search (demo) │
│     target  http://127.0.0.1:8077 │
│      model  open                  │
│       seed  20260720              │
│   duration  15.0s                 │
│   requests  1,100                 │
│ throughput  73.2 req/s            │
╰───────────────────────────────────╯
Latency (ms)
┏━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━┓
┃  p50 ┃  p75 ┃  p90 ┃  p95 ┃   p99 ┃   max ┃ mean ┃
┡━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━┩
│ 46.0 │ 47.9 │ 51.1 │ 82.5 │ 268.3 │ 835.4 │ 49.7 │
└──────┴──────┴──────┴──────┴───────┴───────┴──────┘
Outcomes
┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┓
┃ measure            ┃  value ┃
┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━┩
│ error rate         │ 1.55 % │
│ unexpected status  │     17 │
│ timeouts           │      0 │
│ connection errors  │      0 │
│ rate limited (429) │     12 │
│ cache hit ratio    │ 33.4 % │
│ empty results      │  6.0 % │
└────────────────────┴────────┘
Latency by requested area
┏━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┓
┃ area km² ┃ requests ┃   p50 ┃   p95 ┃   p99 ┃   max ┃
┡━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━┩
│ <0.1     │        6 │  43.7 │  47.0 │  47.0 │  47.0 │
│ 0.1-1    │      150 │  45.2 │  47.6 │  47.9 │  47.9 │
│ 1-10     │      498 │  45.9 │  48.4 │  49.2 │  52.1 │
│ 10-100   │      377 │  47.2 │  72.4 │ 101.9 │ 117.9 │
│ 100-1k   │       69 │ 141.8 │ 457.5 │ 835.6 │ 835.4 │
└──────────┴──────────┴───────┴───────┴───────┴───────┘
Per endpoint
┏━━━━━━━━━━━━━┳━━━━━┳━━━━━━━┳━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━┳━━━━━━━┓
┃ endpoint    ┃ req ┃ req/s ┃  p50 ┃   p95 ┃   p99 ┃ err ┃ cache ┃
┡━━━━━━━━━━━━━╇━━━━━╇━━━━━━━╇━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━╇━━━━━━━┩
│ bbox-search │ 739 │  49.2 │ 46.2 │ 138.1 │ 293.6 │   8 │   33% │
│ nearest     │ 361 │  24.0 │ 45.7 │  49.1 │  52.1 │   9 │   34% │
└─────────────┴─────┴───────┴──────┴───────┴───────┴─────┴───────┘
Thresholds
┏━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┓
┃ gate          ┃  actual ┃ verdict ┃
┡━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━┩
│ p95>250ms     │ 82.5 ms │      ok │
│ error_rate>2% │  1.55 % │      ok │
└───────────────┴─────────┴─────────┘

The aggregate p50 of 46 ms says the service is fine. The area breakdown says something much more specific: everything up to 10 km² is flat at ~46 ms, the 10–100 km² bucket starts to spread, and the 100–1k km² bucket is three times slower at p50 and ten times slower at p99. Only 69 of 1,100 requests landed in that bucket, and they account for essentially the whole tail. That is a statement about one index and one range of query sizes, not about "the API".

During the run a live panel shows progress, current rate, rolling percentiles, error and 429 counts, cache ratio and a per-endpoint breakdown. Pass --no-ui in CI — it is also skipped automatically when stdout is not a terminal.

Scenario file reference

A scenario is a YAML document. Together with the seed it completely determines the traffic, so committing one next to your service gives you a reproducible performance contract.

name: feature search              # optional; defaults to the filename
base_url: https://api.example.com # required; http:// or https://
timeout: 10s                      # per-request timeout
verify_tls: true                  # set false (or pass --insecure) for self-signed certs

headers:                          # sent on every request
  Accept: application/geo+json

auth:                             # optional; becomes request headers
  bearer: $API_TOKEN              # $VAR / ${VAR} resolved from the environment
  # api_key: $KEY
  # header: X-API-Key             # only alongside api_key; defaults to X-API-Key

seed: 20260720                    # the reproducibility knob; --seed overrides it

region: [-0.51, 51.28, 0.33, 51.69]   # inherited by every generator that accepts it
hotspots:                             # ditto — traffic clusters here
  - {name: city, lon: -0.0886, lat: 51.5130, weight: 3.0}
  - {name: westminster, lon: -0.1276, lat: 51.5010, weight: 2.5}

load:
  model: open                     # open (arrival rate) | closed (fixed concurrency)
  warmup: 5s                      # executed, but excluded from all metrics
  max_requests: 20000             # optional hard caps
  max_duration: 90s
  stages:
    - {duration: 20s, rate: 40}   # rate for the open model
    - {duration: 40s, rate: 120}
    # - {duration: 30s, concurrency: 24}   # concurrency for the closed model

endpoints:
  - name: bbox-search
    weight: 6                     # relative share of the request mix
    method: GET
    path: /v1/features?bbox={bbox}&limit=200
    expect_status: [200]          # anything else counts as an error
    headers: {X-Trace: loadgen}   # optional, merged over the global headers
    generator:
      kind: bbox
      area_km2_median: 12
      area_sigma: 1.3

An unset environment variable referenced by auth is a hard error rather than an empty string, so a misconfigured CI job fails loudly instead of sending unauthenticated traffic.

Three complete, realistic scenarios ship in scenarios/: feature-search.yaml (bbox + nearest + detail replay over London), tile-cdn.yaml (vector tiles as pan/zoom sessions over San Francisco) and polygon-intersects.yaml (POST polygon bodies under the closed model over Amsterdam).

URL and body templates

path may contain {placeholders} filled from the generator. Placeholders are validated at parse time — a URL that asks for {z} while the generator is bbox fails immediately with a list of what that generator does provide, rather than after you have started sending traffic.

body is an optional JSON template for POST/PUT endpoints. Strings are interpolated; a string that is exactly one placeholder ("{geometry}") is replaced by the raw object, so GeoJSON is embedded structurally rather than stringified. With no body, generators that produce one — the polygon generator — supply it directly.

    body:
      geometry: "{geometry}"
      buffer_m: 250
      properties: {source: spatial-api-loadgen}

Generators

Every generator accepts region, hotspots, hotspot_ratio (share of samples drawn near a hotspot rather than uniformly, default 0.8), hotspot_sigma_km (Gaussian scatter around the hotspot, default 8) and zipf_exponent (hotspot popularity skew, default 1.1). Unknown keys are rejected with the list of accepted ones.

kind Emits Key options
bbox {bbox}, {min_lon}{max_lat}, {lon}, {lat} area_km2_median (25), area_sigma (1.2), aspect_sigma (0.35), min_area_km2, max_area_km2
point {lon}, {lat}, {radius} / {radius_m} / {radius_km}, {limit} radius_m_median (1000), radius_sigma (0.6), min_radius_m, max_radius_m, limit
polygon {geometry}, {bbox}, {lon}, {lat} + a GeoJSON body vertices (6), radius_km_median (3), radius_sigma (0.5), radius_jitter (0.25), geometry_key
tile {z}, {x}, {y}, {zoom}, {bbox} min_zoom (8), max_zoom (16), start_zoom, session_length (40), zoom_probability (0.15), pan_distance (1)
feature_id {id}, {feature_id} ids (required list), shuffle (true), zipf_exponent

A few notes on why they behave as they do:

  • bbox areas are log-normal, not uniform. area_sigma: 1.2 means about two thirds of windows fall within a factor of ~3.3 of the median, with a long tail of large ones. The tail is the point — it is where index behaviour changes.
  • polygon rings are reduced to their convex hull, so they are always convex and simple. Many ST_Intersects deployments reject or mis-plan self-intersecting input, and you would end up measuring the validation path rather than the query path.
  • tile walks sessions. A client picks a start tile near a hotspot, pans to neighbours, occasionally zooms, and after a geometrically-distributed number of steps a new session starts somewhere else. This is the only way to get a representative CDN hit ratio.
  • feature_id shuffles deterministically before applying Zipf, so popularity rank does not follow the order of your ID file.

Load model

The open model drives a constant arrival rate on a timeline computed up front. If the server stalls, requests keep being scheduled and their latency is measured from when they should have been sent. This is the correction for coordinated omission, and it is why the open model reports harsher tails than many tools — the harsh number is the correct one, because it is what a user population would have experienced.

The closed model holds a fixed number of workers in flight, each sending the next request as soon as the previous returns. Use it for internal consumers with a bounded worker pool, where "what happens at exactly 24 concurrent callers" is the real question. Its latency figures are throughput-limited by construction and are not comparable to open-model figures.

Both models support ramp stages, warmup, and max_requests / max_duration caps.

CLI reference

spatial-api-loadgen run SCENARIO [options]     Execute a scenario and report.
spatial-api-loadgen preview SCENARIO [-n N]    Print requests without sending them.
spatial-api-loadgen validate SCENARIO          Parse and summarise a scenario.
spatial-api-loadgen compare BASELINE CURRENT   Diff two results files.
spatial-api-loadgen report RESULTS --html OUT  Rebuild HTML from saved JSON.
spatial-api-loadgen generators                 List generator kinds.

run options:

Option Effect
--seed N Override the scenario seed. Same seed, same traffic.
--base-url URL Point the same scenario at staging instead of production.
--json PATH Write the machine-readable results document.
--html PATH Write the self-contained HTML report.
--fail-if EXPR Comma-separated gates, e.g. p95>250ms,error_rate>1%. Exit 1 on breach.
--compare PATH Diff against a previous results file.
--tolerance F Fractional change --compare tolerates before flagging (default 0.10).
--fail-on-regression Make --compare regressions exit 1.
--max-in-flight N Ceiling on concurrent requests (default 1000).
--insecure Skip TLS verification.
--no-ui / --quiet Disable the live display / all output.

preview takes -n/--count, --seed and --endpoint NAME to isolate a single endpoint.

Exit codes: 0 all gates passed · 1 a threshold was breached or a regression was flagged · 2 usage, scenario or I/O error, meaning the run never started.

Threshold expressions

metric OP value[unit] where OP is >, >=, < or <=. Prefix a metric with an endpoint name to scope it: tiles.p95>50ms.

Metrics: p50 p75 p90 p95 p99 max mean (ms) · error_rate cache_hit_ratio empty_ratio (ratio, accepts %) · throughput / rps · requests errors timeouts connection_errors unexpected_status rate_limited (counts).

Units: ms, s (converted to ms), % (converted to a ratio), or bare (the metric's canonical unit). A gate on a metric that was not measured — a cache hit ratio when the server sent no cache headers — is reported as skipped and never fails the build.

Metrics explained

Latency percentiles come from a log-linear (HDR-style) histogram implemented in histogram.py, not from retained samples. Values are split into power-of-two buckets, each subdivided linearly, giving constant relative error — within 0.1% of the true value at three significant figures — using about 24k counters no matter how many requests you send. Reported values are the containing bucket's upper bound, so a percentile never understates the truth, which is the safe direction for an SLO check.

Latency by requested area buckets each request's latency by the area of the window it asked for, in decades from <0.1 km² to >100k km². Query cost against a spatial index tends to move with the order of magnitude of the window rather than linearly with it, which is why the buckets are decade-spaced. Failed requests are excluded — a connection error says nothing about query cost.

Cache hit ratio is hits over hits-plus-misses, read from CF-Cache-Status, X-Cache, X-Cache-Status and friends, falling back to Age (a positive Age means a shared cache held the response). Chained values like HIT, MISS resolve to the first recognised token. BYPASS and DYNAMIC are recorded but excluded from the ratio, and if nothing carried cache headers the ratio is reported as n/a rather than as zero.

Rate limiting counts 429s and parses Retry-After (both the seconds and HTTP-date forms) plus the X-RateLimit-* / RateLimit-* families, reporting the mean retry delay and the minimum remaining quota observed.

Empty-result ratio and feature percentiles come from counting features in each response body: FeatureCollection, a bare Feature, a bare geometry, a top-level array, newline-delimited GeoJSON and common envelopes are all understood. Bodies over 2 MiB are scanned rather than parsed. A body that cannot be interpreted yields no count at all, which is deliberately different from a count of zero — an empty FeatureCollection is a real measurement and must not be conflated with "we could not tell".

Errors are split into unexpected HTTP statuses, timeouts, connection errors and other transport failures. The split matters: a connection error generally means the listen backlog or the database pooler ran out before the application did, which is a different fix from a slow query.

Scheduler lag records how far behind the ideal timeline the generator itself fell. If it is large, the load generator was the bottleneck and the run should be treated with suspicion — raise --max-in-flight, or drive the target from more than one machine.

Report

--html report.html writes a single self-contained file. There is no JavaScript, no CDN, no external stylesheet, font or image — CSS is inlined and every chart is hand-built SVG, so the report renders identically on a laptop with no network and will still render in five years. It adapts to light and dark themes, and SVG <title> elements give native hover tooltips on every mark without a line of script. The test suite asserts all of this.

The report contains:

  • A header with the scenario, target, model, seed and duration, and a row of stat tiles (requests, throughput, p50/p95/p99, error rate, cache hits).
  • Latency percentiles — a bar chart with direct value labels.
  • Latency by requested area — grouped bars of p50/p95/p99 per area bucket, with a legend and the same data as a table, plus a per-endpoint breakdown where one is meaningful.
  • Throughput and errors over time — a throughput area chart with an errors-per-second strip beneath it sharing the same time axis (two panels rather than two y-scales on one chart).
  • Cache behaviour — a hit-ratio meter and a table of observed cache statuses.
  • Errors, timeouts and rate limiting — a table of every outcome, with a callout when 429s were seen.
  • Result sizes — feature-count percentiles, empty ratio and response byte sizes.
  • Per-endpoint results — the full table.

Each block carries a small "Tuning this metric →" link to the guide that explains how to move that particular number, chosen from what the run actually observed: connection errors link to the connection-pooling guide, 429s to the rate-limiting guide, big-bbox latency to the bbox index guide, and so on.

--json run.json writes the same numbers as a stable document — metadata, overall figures, per-endpoint figures, the area breakdown and a per-second timeline. It is the input to compare and to report.

Interpreting results

Start with the area breakdown, not the aggregate. A flat line across buckets means the index is carrying the query and your latency is dominated by something else. A curve that lifts sharply past one bucket is the point at which the planner stopped using the index; that bucket's boundary is the number to take to EXPLAIN (ANALYZE, BUFFERS).

Check the empty-result ratio before you trust anything. If it is high, your region and hotspots do not match where your data actually is, and the run measured mostly-empty queries. Fix the scenario and run again.

A cache hit ratio near zero on a tile endpoint usually indicts the scenario, not the CDN. Confirm session_length is realistic and hotspot_ratio is high before concluding anything about the edge.

Compare open-model and closed-model numbers with care. They answer different questions and their latency figures are not interchangeable. If the open model reports a far worse tail than a tool you used previously, that is expected — see the coordinated-omission discussion above.

Watch scheduler lag. If it is a significant fraction of your latencies, you measured your load generator.

For CI, the pattern that works is a short scenario with a fixed seed, absolute gates for the things you have an SLO on, and --compare against a stored baseline for everything else:

$ spatial-api-loadgen compare run1.json run2.json
Comparison vs baseline (tolerance ±10%)
┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓
┃ scope       ┃ metric          ┃ baseline ┃ current ┃ change ┃
┡━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩
│ overall     │ p50             │     46.0 │    46.0 │  +0.1% │
│ overall     │ p95             │     82.5 │    90.4 │  +9.5% │
│ overall     │ p99             │    268.3 │   260.9 │  -2.8% │
│ overall     │ error_rate      │    1.55% │   1.64% │  +5.9% │
│ overall     │ throughput      │     73.2 │    73.0 │  -0.3% │
│ overall     │ cache_hit_ratio │   33.36% │  33.27% │  -0.3% │
│ bbox-search │ p50             │     46.2 │    46.4 │  +0.3% │
│ bbox-search │ p95             │    138.1 │   130.5 │  -5.5% │
│ bbox-search │ p99             │    293.6 │   393.7 │ +34.1% │
│ bbox-search │ error_rate      │    1.08% │   1.35% │ +25.0% │
│ bbox-search │ throughput      │     49.2 │    49.0 │  -0.3% │
│ bbox-search │ cache_hit_ratio │   32.88% │  33.42% │  +1.6% │
│ nearest     │ p50             │     45.7 │    45.7 │  -0.1% │
│ nearest     │ p95             │     49.1 │    48.8 │  -0.5% │
│ nearest     │ p99             │     52.1 │    51.1 │  -2.0% │
│ nearest     │ error_rate      │    2.49% │   2.22% │ -11.1% │
│ nearest     │ throughput      │     24.0 │    23.9 │  -0.3% │
│ nearest     │ cache_hit_ratio │   34.35% │  32.96% │  -4.0% │
└─────────────┴─────────────────┴──────────┴─────────┴────────┘
2 regression(s)

Direction is handled per metric: rising latency and error rate are regressions, falling throughput and cache hit ratio are regressions. Both runs above used the identical seed and therefore the identical request sequence — the differences are genuine server-side variance, which is exactly why the default tolerance is 10% and why you should see how noisy your own baseline is before setting it tighter.

Testing

$ pytest
451 passed in 15.65s

$ ruff check . && ruff format --check .
All checks passed!
27 files already formatted

The suite is offline by construction. HTTP tests drive the real runner and the real httpx client against an in-process ASGI application through httpx.ASGITransport, or against httpx.MockTransport where a specific failure needs injecting. No socket is opened, no server process is started and nothing touches the network, so the tests behave the same on a laptop and in a sandboxed CI runner.

Coverage includes generator determinism and distribution properties (statistically checked with generous tolerances against fixed seeds), URL and body templating, scenario validation error paths, histogram accuracy against exact percentiles on synthetic log-normal and bimodal data, scheduler timing and coordinated-omission correction, cache and rate-limit header classification, GeoJSON feature counting including oversized and malformed bodies, full end-to-end runs against a fake API with area-dependent latency, JSON and HTML report generation, and the comparison and exit-code logic.

CI runs ruff and pytest on Python 3.10, 3.11, 3.12 and 3.13.

Further reading

The documentation site these tuning links point at covers the server side of everything measured here:

License

MIT — see LICENSE.

About

Load-testing harness for spatial HTTP APIs: hotspot-weighted bbox and tile request mixes, coordinated-omission-corrected latency, cache-hit ratio and latency-vs-bbox-area analysis.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages