Configuring Failure Thresholds for WMS Endpoints
Pick a failure threshold for a WMS endpoint by deciding two numbers together: how many bad GetMap responses count as “unhealthy” and over what window you measure them. For flaky OGC servers, consecutive-failure counting trips too eagerly on isolated blips, while a sliding failure-rate window over the last N requests tracks sustained degradation far more faithfully. This page shows how to define what counts as a failure, size the window, and tune per-endpoint values, with a working pybreaker configuration wrapping a live tile fetch.
This is a companion how-to under Circuit Breakers for External WMS Services, which covers the broader state machine; here we drill into the single hardest knob to get right.
When to Use This Pattern
Reach for deliberate threshold tuning when any of these hold:
- You call more than one WMS or WFS provider and they have visibly different reliability profiles.
- Your breaker trips during normal operation (false positives) or never trips during a real outage (false negatives).
- The endpoint returns HTTP 200 with truncated or malformed imagery that your current failure predicate ignores.
- You run many orchestration workers and need each to react to sustained failure rates, not one-off timeouts.
- Latency, not error codes, is your dominant failure mode and you want slow responses to count against health.
Two Counting Strategies
Before any code, settle the counting model. The two dominant approaches trade sensitivity for stability.
Consecutive-failure counting trips after k failures land back-to-back. A single success resets the counter to zero. This is what the default pybreaker.CircuitBreaker(fail_max=k) implements. It is simple and cheap, but a WMS server that alternates success/failure under partial load can stay closed forever because each success wipes the tally. It also reacts slowly to a server that fails 40% of requests but never four times in a row.
Failure-rate counting over a sliding window trips when the failure ratio across the last N observed requests exceeds a threshold (say 50% of the last 20). This mirrors how production libraries such as Resilience4j and Istio model breaker health, and it captures intermittent degradation that consecutive counting misses. The cost is that you must retain a small ring buffer of recent outcomes and you need a minimum-samples floor so the rate is not computed from two requests.
For external WMS endpoints, prefer failure-rate counting. Spatial tile servers rarely fail cleanly; they wobble. A rate window over the last 20–50 requests, with a minimum of 10 samples before the rate is trusted, is a durable starting point.
What Counts as a Failure
A breaker is only as good as its failure predicate. For WMS/WFS, count these as failures against the window:
- HTTP 5xx (500, 502, 503, 504) — server-side degradation, the canonical trip signal.
- Connect or read timeouts — a
GetMapthat streams a large extent and stalls is worse than a fast 500. - Malformed tile payloads — HTTP 200 with a truncated PNG/TIFF, a body under a sane byte floor, or a
Content-Typeofapplication/vnd.ogc.se_xml(a WMSServiceExceptionmasquerading as success).
Explicitly do not count HTTP 4xx (400, 401, 404). A malformed BBOX, an unknown layer, or a bad API key is a caller defect; counting it toward server health opens the breaker on inputs that will never succeed no matter how healthy the server is. This mirrors the exclusion guidance in the parent section and keeps the breaker measuring the provider, not your parameters.
Complete Working Example
The example wraps a real GetMap call. It uses a custom sliding-window breaker so the failure-rate semantics are explicit, then feeds a strict failure predicate that inspects status, byte size, and content type.
from __future__ import annotations
import collections
import time
from dataclasses import dataclass, field
import requests
@dataclass
class SlidingWindowBreaker:
"""Failure-rate breaker over the last `window` observed requests."""
window: int = 20
min_samples: int = 10
failure_rate: float = 0.5 # trip at 50% failures
reset_timeout: float = 60.0 # seconds before a probe is allowed
_outcomes: collections.deque = field(default_factory=lambda: collections.deque())
_opened_at: float | None = None
def allows_request(self) -> bool:
if self._opened_at is None:
return True
if time.monotonic() - self._opened_at >= self.reset_timeout:
return True # half-open probe window (see the recovery guide)
return False
def record(self, failed: bool) -> None:
self._outcomes.append(failed)
while len(self._outcomes) > self.window:
self._outcomes.popleft()
if len(self._outcomes) < self.min_samples:
return
rate = sum(self._outcomes) / len(self._outcomes)
if rate >= self.failure_rate:
self._opened_at = time.monotonic()
elif failed is False:
self._opened_at = None # healthy again, close the circuit
def is_wms_failure(resp: requests.Response) -> bool:
"""Failure predicate: 5xx, tiny bodies, or an OGC ServiceException."""
if resp.status_code >= 500:
return True
ctype: str = resp.headers.get("Content-Type", "")
if "se_xml" in ctype or "text/xml" in ctype:
return True # WMS error returned under HTTP 200
if len(resp.content) < 1024:
return True # truncated or empty raster tile
return False
def fetch_getmap(breaker: SlidingWindowBreaker, base_url: str, bbox: str) -> bytes:
if not breaker.allows_request():
raise RuntimeError("WMS breaker OPEN — endpoint marked unhealthy")
params: dict[str, str] = {
"SERVICE": "WMS",
"VERSION": "1.3.0",
"REQUEST": "GetMap",
"LAYERS": "topo",
"CRS": "EPSG:3857",
"BBOX": bbox,
"WIDTH": "512",
"HEIGHT": "512",
"FORMAT": "image/png",
}
try:
resp = requests.get(base_url, params=params, timeout=(3.0, 15.0))
except requests.exceptions.RequestException:
breaker.record(failed=True) # timeout / connection reset counts
raise
failed: bool = is_wms_failure(resp)
breaker.record(failed=failed)
if failed:
raise RuntimeError(f"WMS GetMap failed: HTTP {resp.status_code}")
return resp.content
Note the (3.0, 15.0) timeout tuple: a 3-second connect ceiling and a 15-second read ceiling. Omitting the read timeout is the single most common reason a breaker never trips — the request hangs instead of failing, so no failure is ever recorded. The predicate treats an XML Content-Type as failure because GeoServer and MapServer both emit a ServiceException body with a 200 status when a layer is temporarily unavailable.
Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
window |
int |
20 | Requests retained per endpoint; larger smooths bursty tile traffic |
min_samples |
int |
10 | Floor before the rate is trusted; avoids tripping on cold start |
failure_rate |
float |
0.5 | Trip ratio; lower for critical basemaps, higher for optional overlays |
reset_timeout |
float |
60.0 | Seconds before a half-open probe; jitter this across workers |
| tile byte floor | int |
1024 | Minimum plausible PNG/TIFF tile; below this = truncated payload |
| connect timeout | float |
3.0 | Fast fail on unreachable hosts before the read phase |
| read timeout | float |
15.0 | Bounds slow GetMap streams so stalls register as failures |
Per-Endpoint Tuning
A single global breaker averages together providers with unrelated health, so keep one breaker instance per host and tune each. A national mapping agency’s cached basemap might tolerate failure_rate=0.6 because occasional 503s under load are expected and cheap to retry. A slow dynamic-rendering WFS feeding a critical join might warrant failure_rate=0.35 and a longer reset_timeout so it is genuinely rested before probing. Store the tunables in per-endpoint config rather than code, and let the exponential backoff for API rate limits layer handle in-window transient 429s so the breaker only sees structural failure.
Verification & Testing
Drive the breaker with a fake response sequence and assert the trip point. This unit sketch confirms a 50% rate over a 20-request window opens exactly when expected:
def test_breaker_trips_at_failure_rate() -> None:
breaker = SlidingWindowBreaker(window=20, min_samples=10, failure_rate=0.5)
# 9 clean requests: below min_samples, must stay closed
for _ in range(9):
breaker.record(failed=False)
assert breaker.allows_request() is True
# Now feed failures until the last-20 rate crosses 50%
for _ in range(11):
breaker.record(failed=True)
assert breaker.allows_request() is False # 11/20 failures -> OPEN
def test_four_xx_is_excluded() -> None:
import types
resp = types.SimpleNamespace(
status_code=404, headers={"Content-Type": "image/png"}, content=b"x" * 2048
)
assert is_wms_failure(resp) is False # client error, not server health
For a live smoke test, point fetch_getmap at a known-bad BBOX on a real GeoServer and confirm the XML ServiceException path records a failure, then curl the same endpoint with --max-time 15 to verify your read timeout matches the server’s slow-response ceiling.
Common Pitfalls
- Counting 4xx as server failures. A bad
LAYERSname or missingCRSopens the breaker permanently against inputs that can never succeed. Exclude all 4xx from the failure predicate. - No read timeout. Without a read ceiling, a stalled
GetMaphangs the worker and never records a failure, so the breaker stays closed through the entire outage. - Consecutive counting on a wobbly server. A provider that alternates success and failure resets a consecutive counter every other request and never trips; use a sliding rate window instead.
- Sharing one breaker across endpoints. Averaging a healthy basemap with a failing WFS masks both signals; instantiate one breaker per host and store thresholds in per-endpoint config.
Related: Continue with half-open recovery for tile servers to tune the probe phase, layer this beneath exponential backoff for API rate limits, route tripped requests through dead-letter queues for failed geotasks, and keep deferred replays safe with idempotency keys in spatial ETL. All of these sit within Resilience & Failure Handling for GIS Pipelines.