Half-Open Recovery for Tile Servers
Half-open is the state where a breaker asks “is it back yet?” — and the whole art is asking cheaply. Permit exactly one in-flight probe across the entire fleet, make that probe the least expensive request the endpoint can serve, require several consecutive successes before closing, and ramp traffic back rather than releasing sixty workers at once. A recovering tile server is fragile: it has cold caches, a cold connection pool and often a backlog to work through, and the fastest way to knock it over again is to treat “one success” as “recovered”.
When to Use This Pattern
- A shared breaker exists and you are implementing or fixing its recovery path, which is where most breaker bugs live.
- The endpoint is expensive per request — a rendering WMS, a tile server that reads from object storage on a miss — so the cost of probing matters.
- Several workers share the breaker state, meaning “one probe” has to be enforced with a lock rather than assumed.
- You have seen the breaker flap: open, close, open again within a minute. That is nearly always a recovery path that closes too eagerly.
Complete Working Example
The probe protocol below uses a Redis SET NX EX as the fleet-wide permit, a deliberately cheap probe request, and a consecutive-success counter that any failure resets.
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
import httpx
import redis
@dataclass(frozen=True)
class ProbeConfig:
lock_seconds: int = 20 # must exceed the probe's own timeout
probes_to_close: int = 3
probe_timeout: float = 8.0
ramp_seconds: int = 30 # partial traffic for this long after closing
class HalfOpenProbe:
def __init__(self, r: redis.Redis, endpoint: str, cfg: ProbeConfig = ProbeConfig()):
self.r = r
self.cfg = cfg
self.k_permit = f"cb:{endpoint}:probe_permit"
self.k_streak = f"cb:{endpoint}:probe_streak"
self.k_ramp = f"cb:{endpoint}:ramp_until"
def acquire(self) -> bool:
"""Exactly one worker fleet-wide gets True. NX is what makes this atomic."""
return bool(self.r.set(self.k_permit, "1", nx=True, ex=self.cfg.lock_seconds))
def release(self) -> None:
self.r.delete(self.k_permit)
def run(self, client: httpx.Client, base_url: str, layer: str) -> Optional[bool]:
"""Probe once. True = success, False = still failing, None = not our turn."""
if not self.acquire():
return None
try:
# The cheapest meaningful request: a 1x1 GetMap over a tiny extent.
# GetCapabilities is cheaper still but is often served from a cache and
# succeeds while the renderer itself is dead — it proves nothing.
response = client.get(
base_url,
params={
"service": "WMS", "version": "1.3.0", "request": "GetMap",
"layers": layer, "styles": "",
"crs": "EPSG:3857",
"bbox": "0,0,100,100", # 100 m square: no real work to render
"width": "1", "height": "1",
"format": "image/png",
},
timeout=httpx.Timeout(4.0, read=self.cfg.probe_timeout),
)
ok = (
response.status_code == 200
and response.headers.get("content-type", "").startswith("image/")
and len(response.content) > 0
)
except httpx.HTTPError:
ok = False
finally:
self.release()
if not ok:
self.r.delete(self.k_streak) # any failure resets the streak
return False
streak = self.r.incr(self.k_streak)
self.r.expire(self.k_streak, 300)
if streak >= self.cfg.probes_to_close:
self.r.delete(self.k_streak)
# Closing starts a ramp window rather than releasing the full fleet.
self.r.set(self.k_ramp, str(time.time() + self.cfg.ramp_seconds),
ex=self.cfg.ramp_seconds)
return True
def ramp_fraction(self) -> float:
"""0..1 — the share of workers allowed through during the ramp window."""
raw = self.r.get(self.k_ramp)
if raw is None:
return 1.0
remaining = float(raw) - time.time()
if remaining <= 0:
return 1.0
return max(0.1, 1.0 - remaining / self.cfg.ramp_seconds)
Two choices in there are worth defending. The probe is a GetMap rather than a GetCapabilities because capabilities documents are almost always cached in front of the renderer — they will happily return 200 while the thing that draws maps is dead, which produces a breaker that closes into an outage. And the permit’s TTL must be longer than the probe’s own timeout: if the lock expires while a probe is still in flight, a second worker acquires it and you have two probes, which is the exact situation the lock exists to prevent.
Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
lock_seconds |
int |
20 |
Must exceed probe_timeout. A lock that expires mid-probe permits a second probe, defeating the design. |
probes_to_close |
int |
3 |
Consecutive, and reset by any failure. Two is too eager for a renderer with cold caches; five is slow to recover. |
probe_timeout |
float |
8.0 |
Shorter than the normal read timeout. A probe that hangs for three minutes tells you nothing you did not already know. |
| probe request | GetMap 1×1 |
— | A tiny bbox at 1×1 pixels. Cheap for the server, and unlike GetCapabilities it exercises the renderer. |
ramp_seconds |
int |
30 |
Traffic is scaled from 10% to 100% across this window after closing. |
| streak TTL | 300 s |
— | A streak that stalls half-finished should expire rather than persist into the next incident. |
Verification & Testing
The test that catches the real bug is the concurrent one: many workers, one probe.
import threading
def test_only_one_probe_escapes(fake_redis, probe_server) -> None:
probe = HalfOpenProbe(fake_redis, endpoint="wms.example:ortho")
barrier = threading.Barrier(12)
results: list[Optional[bool]] = []
lock = threading.Lock()
def attempt() -> None:
barrier.wait() # maximise the chance of a real race
with httpx.Client() as client:
outcome = probe.run(client, probe_server.url, layer="ortho")
with lock:
results.append(outcome)
threads = [threading.Thread(target=attempt) for _ in range(12)]
for t in threads:
t.start()
for t in threads:
t.join()
assert probe_server.request_count == 1, "more than one probe reached the server"
assert results.count(None) == 11 # the other eleven were refused
def test_failure_resets_the_streak(fake_redis, flapping_server) -> None:
probe = HalfOpenProbe(fake_redis, endpoint="wms.example:ortho")
with httpx.Client() as client:
assert probe.run(client, flapping_server.ok_url, "ortho") is True
assert probe.run(client, flapping_server.ok_url, "ortho") is True
assert probe.run(client, flapping_server.fail_url, "ortho") is False
# Two successes then a failure must NOT be one success away from closing.
assert fake_redis.get("cb:wms.example:ortho:probe_streak") is None
In production, the metric worth graphing is time-in-state. A healthy recovery looks like a single open period followed by a half-open period of a minute or two and then closed. A pathological one looks like a comb — open, half-open, open, half-open — which means the probes are succeeding against something that is not really ready, and probes_to_close or the cool-down needs raising.
There is a subtler reading of the comb pattern worth knowing. Sometimes the probes are not wrong at all — the server really can serve a 1×1 image, and really cannot serve the fleet. That is not a probe problem, it is a capacity problem, and no amount of tuning probes_to_close will fix it. The tell is that the failures after each close arrive within a second or two and are timeouts rather than errors. When you see that, the answer is to lower the per-endpoint concurrency limit permanently, not to make the breaker more patient: the endpoint is telling you, quite precisely, how much traffic it can take.
Common Pitfalls
- Probing with
GetCapabilities. It is usually served by a cache or a lightweight metadata path, so it succeeds while the renderer is still dead. The breaker closes, the fleet arrives, and the breaker re-opens ten seconds later — the classic flapping signature. - A permit TTL shorter than the probe timeout. The lock expires, another worker acquires it, and now two probes are in flight against a server that could not handle one. Assert
lock_seconds > probe_timeoutin configuration validation. - Closing on a single success. One probe proves the server can render a 1×1 image, not that it can carry sixty workers. Consecutive successes plus a ramp are what make the difference.
- Releasing the whole fleet at once. Even after three clean probes, sixty concurrent renders against a cold cache is a load spike the server just proved it is sensitive to. Ramp, or lean on the per-endpoint concurrency limit to ramp for you.
- No timeout on the probe. A probe that inherits the pipeline’s generous read timeout holds the permit for minutes, during which the breaker cannot re-evaluate anything. Probes should be short and decisive.
Frequently Asked Questions
Should the probe be a real tile from the batch?
It is tempting — a successful probe would then be useful work — but it makes the probe expensive and variable. A real tile might be a heavy render, so a slow response tells you about the tile rather than about the server. Keep the probe synthetic and cheap; the first real tile after the ramp is where useful work resumes.
What if the endpoint has no cheap request?
Some do not — every GetMap costs seconds regardless of extent. Then accept the cost and lengthen the cool-down instead, so probes are rarer. It is also worth asking the operator whether a health endpoint exists; many services have one that is not advertised in the capabilities document.
How does the ramp interact with the concurrency limit?
They multiply. If the per-endpoint limit is 16 and the ramp fraction is 0.25, four requests are in flight. That is usually what you want, and it means you can often skip an explicit ramp implementation by simply lowering the concurrency limit for the first minute after a close — see limiting DAG fan-out with concurrency groups.
Does anything need to happen if the probe never runs?
Yes — if the batch finished while the breaker was open, no worker arrives to probe and the breaker stays open into the next run. A tiny scheduled flow that does nothing but call run() every minute keeps recovery detection alive between batches, and costs one request a minute at most.
Related
- Circuit breakers for external WMS services — the state machine this implements the recovery half of
- Configuring failure thresholds for WMS endpoints — where the cool-down that precedes half-open comes from
- Falling back to cached tiles when a breaker opens — what the fleet serves while probing
- Exponential backoff for API rate limits — the retry layer that resumes after the ramp