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.
- 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.jsonfor regression detection, both with meaningful exit codes.
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.
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 --helpCheck 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-1004Then 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.
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.3An 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).
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}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:
bboxareas are log-normal, not uniform.area_sigma: 1.2means 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.polygonrings are reduced to their convex hull, so they are always convex and simple. ManyST_Intersectsdeployments reject or mis-plan self-intersecting input, and you would end up measuring the validation path rather than the query path.tilewalks 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_idshuffles deterministically before applying Zipf, so popularity rank does not follow the order of your ID file.
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.
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.
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.
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.
--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.
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.
$ pytest
451 passed in 15.65s
$ ruff check . && ruff format --check .
All checks passed!
27 files already formattedThe 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.
The documentation site these tuning links point at covers the server side of everything measured here:
- Query plan analysis and index tuning — what to do once the area breakdown shows where the index stops helping.
- Bounding-box spatial index queries — designing the bbox endpoint the
bboxgenerator hammers. - Redis caching for spatial queries and tile generation and CDN distribution — moving the cache hit ratio.
- Connection pooling with PgBouncer — the usual cause of connection errors under load.
- Rate limiting geofence and tile endpoints — for when the run is full of 429s.
- Spatial pagination and cursor strategies — for when the features-returned percentiles are the problem.
MIT — see LICENSE.