Serving Hazard Rasters Over Range Requests
A command vehicle parks on a ridge with a satellite terminal, opens the regional flood-depth COG, and waits eleven seconds for a county-wide view that should have taken three. The file is correct, the server supports ranges, and the object store is fast. The problem is that a reverse proxy in front of the store is buffering responses and stripping Range, so every “partial” read is a full object fetch that the proxy truncates before forwarding.
Root Cause and Operational Impact
A Cloud-Optimized GeoTIFF’s entire benefit is contractual rather than intrinsic: the file promises that a small number of byte ranges answer a window query, and every hop between the client and the bytes has to honour that promise. Producing a correct COG is the easy half. The delivery path — object store, signing scheme, CDN, reverse proxy, and the client’s own configuration — is where the property is quietly lost, and losing it anywhere produces the same symptom.
The symptom is the reason this is worth its own guide. A path that has stopped honouring ranges does not error; it returns correct pixels, slowly, so the report that reaches an engineer is “the map is slow on satellite”, which is also what genuine bandwidth constraints look like. Distinguishing them requires counting requests and bytes rather than timing the operation.
The second failure mode is subtler and belongs to the file rather than the path: a COG whose window read needs forty requests instead of four. On a terrestrial link nobody notices. On the link a forward node actually has, latency dominates completely.
At 700 milliseconds round-trip, forty sequential range requests cost 28 seconds of pure waiting regardless of how few bytes they carry. This is why block size and tile contiguity are worth tuning even when the byte totals look similar — the quantity that matters over satellite is round trips, and the fix is at production time.
Tiered Resolution Strategy
- Verify the path preserves ranges before blaming the file (definitive). A single
curlwith aRangeheader against the published URL, checking for206 Partial Contentand aContent-Rangeheader, settles it in seconds. Run it against the real published URL, not the origin — the proxy is the usual culprit and it is invisible from inside. - Count requests and bytes, not elapsed time. GDAL’s
CPL_CURL_VERBOSEreports every range it issues. Four to six for a window is healthy; dozens means the file is tiled wrongly or the client’s block cache is undersized. - Tune the client’s read window to the file’s block size. A client reading in 256-pixel windows against a 512-pixel-blocked file fetches each block up to four times. Aligning them is a client configuration change, not a rebuild.
- Pre-fetch on degradation, not on failure (safe default). When round-trip time or error rate crosses a threshold, stage the division’s overview levels locally while there is still bandwidth to do it. Waiting for a request to fail means waiting until staging is impossible.
- Emit an audit record of range counts per session. A node whose request count per window has quietly doubled has had something change in its path, and that is worth knowing before the next incident.
Tier four is the one that has to be designed rather than added later. The window in which a forward node can prepare for an outage is the degraded period before it, and by the time a request actually fails there is no capacity left to stage anything. Trigger on the leading indicators — rising round-trip time, rising retransmits, a falling success rate — and treat the staging job as something that competes for the last usable bandwidth rather than as a background task.
Production Python Implementation
from __future__ import annotations
import logging
import os
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
import rasterio
from rasterio.errors import RasterioIOError
logger = logging.getLogger("incidentgis.cog_serving")
RANGE_LOG = re.compile(rb"Range: bytes=")
HEALTHY_REQUESTS_PER_WINDOW = 8
@dataclass(frozen=True)
class ReadProfile:
"""What one window read actually cost on the wire."""
requests: int
bytes_moved: int
honoured_ranges: bool
def probe_window_read(url: str, *, col: int, row: int, size: int = 512) -> ReadProfile:
"""Read one window over HTTP and report requests, bytes and range support.
Deliberately shells out to gdal_translate so the measurement covers the
real GDAL/curl path the field client uses, including its proxy settings,
rather than a Python HTTP client that would bypass them.
"""
env = {
**os.environ,
"CPL_CURL_VERBOSE": "YES",
"CPL_VSIL_CURL_USE_HEAD": "YES",
# Match the client's block cache to the file, or every block is
# fetched more than once and the request count multiplies.
"GDAL_CACHEMAX": "256",
}
cmd = [
"gdal_translate", "-q",
"-srcwin", str(col), str(row), str(size), str(size),
f"/vsicurl/{url}", "/vsimem/probe.tif",
]
proc = subprocess.run(cmd, env=env, capture_output=True, timeout=120)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.decode("utf-8", "replace")[-500:])
requests = len(RANGE_LOG.findall(proc.stderr))
honoured = b"206" in proc.stderr or b"Partial Content" in proc.stderr
bytes_moved = sum(
int(m) for m in re.findall(rb"Content-Length: (\d+)", proc.stderr)
)
profile = ReadProfile(requests, bytes_moved, honoured)
if not profile.honoured_ranges:
# The file may be perfect; the path is not. Say so explicitly, because
# the alternative report is "the map is slow", which sends an engineer
# to the wrong system.
logger.error("range_requests_not_honoured", extra={"url": url})
elif profile.requests > HEALTHY_REQUESTS_PER_WINDOW:
logger.warning("excessive_range_requests", extra={
"url": url, "requests": profile.requests,
"hint": "check internal block size against the client read window",
})
logger.info("window_read_profiled", extra={
"url": url, "requests": profile.requests,
"bytes": profile.bytes_moved, "ranges": profile.honoured_ranges,
})
return profile
def stage_division_overviews(url: str, destination: Path, *, bounds) -> Path:
"""Write a clipped local copy while bandwidth still exists.
Called on a degradation signal, not on a failure: once requests are
failing there is no capacity left to stage anything.
"""
try:
with rasterio.open(f"/vsicurl/{url}") as src:
window = src.window(*bounds)
# Read from the coarsest overview that still satisfies the
# division's display scale — staging full resolution over a
# degrading link is how staging jobs fail to finish.
data = src.read(1, window=window, masked=True,
out_shape=(1, int(window.height // 8),
int(window.width // 8)))
profile = src.profile.copy()
profile.update(
height=data.shape[-2], width=data.shape[-1],
transform=src.window_transform(window) * src.transform.scale(8, 8),
driver="GTiff", tiled=True, blockxsize=512, blockysize=512,
)
with rasterio.open(destination, "w", **profile) as dst:
dst.write(data.filled(src.nodata), 1)
dst.update_tags(**src.tags())
except RasterioIOError as exc:
logger.error("division_staging_failed", exc_info=exc)
raise
logger.info("division_staged", extra={"destination": str(destination)})
return destination
Validation Checklist
- A
curl -H 'Range: bytes=0-1023'against the published URL returns206and aContent-Rangeheader. - The check runs against the URL field nodes actually use, not the origin bucket.
- A single window read issues fewer than about eight range requests.
- The client’s read window is aligned to the file’s internal block size.
-
GDAL_CACHEMAXis large enough that a block is not re-fetched within one render. - Staging is triggered by a degradation signal — round-trip time, error rate — and not by a failed request.
- The staged local copy is read from an overview level, not from full resolution.
- Range counts per session are logged, so a change in the delivery path is visible before the next incident.
Edge Cases and Gotchas
- A CDN that caches whole objects. Some edge configurations fetch and cache the full object on first request, then serve ranges from the cache. The first client pays for the whole file and subsequent ones do not, which makes the problem intermittent and very hard to reproduce.
- Signed URLs that exclude the Range header from the signature. Some signing schemes reject requests carrying headers not covered by the signature. The failure is a 403 on the second request only, after the header read has already succeeded.
CPL_VSIL_CURL_USE_HEAD=NOmasking the diagnosis. Disabling the HEAD request is a common tuning tweak and it removes the one placeAccept-Rangesis visible. Leave it on while diagnosing.- Overviews present but the client ignoring them. A client asked for a specific resolution rather than a display scale will read full resolution regardless of the pyramid. Confirm the read is going to an overview level, not just that overviews exist.
- Staging that finishes after the link dies. A staging job with no deadline will happily still be running when connectivity goes. Give it a hard time budget derived from the current throughput, and prefer a coarser complete copy over a finer incomplete one.
Frequently Asked Questions
How do I tell whether a slow raster read is the file or the delivery path? Count requests and bytes rather than timing the read. A single Range request against the published URL should return 206 Partial Content with a Content-Range header; if it returns 200 with the whole object, some hop is stripping the header and the file is irrelevant. If ranges are honoured but a window read issues dozens of requests, the file’s internal tiling or the client’s read window is wrong. Both failures present identically as ‘the map is slow on satellite’, which is why elapsed time is the one measurement that cannot distinguish them.
Why does request count matter more than bytes on a satellite link? Because latency dominates. At a 700-millisecond round trip, forty sequential range requests cost about 28 seconds of pure waiting no matter how few bytes each carries, while four requests cost about 3 seconds. On a 30-millisecond terrestrial link the same two files differ by roughly a second and nobody notices. Since forward nodes are exactly the consumers on high-latency links, block size and tile contiguity are worth tuning at production time even when the byte totals look similar.
When should a forward node stage a local copy instead of reading remotely? On the first sign of degradation, not on the first failure. Staging needs bandwidth, and by the time requests are failing there is none left, so a job triggered by an outage never completes. Trigger on rising round-trip time, rising retransmits or a falling success rate, give the job a hard time budget derived from current throughput, and stage from an overview level rather than full resolution — a coarser complete copy is worth far more in the field than a finer incomplete one.
Related
- Raster Hazard Layers & Cloud-Optimized GeoTIFF — the production contract that makes a small range read possible in the first place.
- Sizing COG Overviews for Field Display Scales — the pyramid the situational-scale read depends on.
- Handling Cache Invalidation During Multi-Day Incidents — how the staged copy is kept current once the node is back on a usable link.
- FlatGeobuf vs GeoPackage for Offline Caching — the vector equivalent, where the same range-read property decides the format.
Up: Raster Hazard Layers & Cloud-Optimized GeoTIFF