How to Add Retries and Timeouts to an Automated GIS Job in Python
On this page
Not every failure means something is broken. A WFS server drops one request in fifty. A network share goes unavailable for nine seconds while a backup runs. A database refuses a connection because the pool was momentarily full. Run a job by hand and you shrug and press up-arrow; run it at 02:15 with nobody watching and that same nine-second blip becomes a failed pipeline and a stale layer. Retries make transient failures invisible — and, done carelessly, they turn a two-minute failure into a two-hour hang. This article covers both halves: retrying what deserves it, and putting a hard ceiling on how long anything is allowed to take.
Problem statement
An automated GIS job fails on things that would have worked if tried again. Typically:
- Flaky network reads — a WFS or tile request fails once, and the whole run dies with it.
- Contended files — a file on a network share is locked by another process for a few seconds.
- Database hiccups — a PostGIS connection is refused during a failover, then works fine a second later.
- Jobs that hang forever — a request to a dead server never returns, and the job is still "running" the next morning.
- Retrying the wrong things — a missing file or a bad CRS is retried five times, turning an instant failure into a slow one with the same outcome.
Transient failures should be retried with backoff, permanent ones raised at once, and every external call bounded by a timeout so the job cannot hang. A real service cannot be made to fail on cue, so the examples run against a small local server that serves the ONS boundary of Rutland and misbehaves on purpose: some paths answer 503 a set number of times, one returns 404, one rate-limits, and one accepts the connection and never answers.
Quick answer
Wrap only the calls that touch something outside your process — network, database, remote filesystem — in a retry with exponential backoff and jitter. Decide what to retry with a predicate, not a broad exception type, and always pass an explicit timeout. For a remote vector file, fetch the bytes yourself — where you control both — and hand them to GeoPandas.
RETRY_STATUS = {408, 429, 500, 502, 503, 504}
def is_transient(exc: Exception) -> bool:
if isinstance(exc, requests.HTTPError):
return exc.response.status_code in RETRY_STATUS
return isinstance(exc, (requests.ConnectionError, requests.Timeout, TimeoutError, ConnectionError,
BlockingIOError))
def with_retry(fn, *args, attempts=4, base_delay=0.5, retry_if=is_transient, **kwargs):
"""Call fn, retrying transient failures with exponential backoff + jitter."""
start = time.monotonic()
for attempt in range(1, attempts + 1):
try:
return fn(*args, **kwargs)
except Exception as exc:
if not retry_if(exc):
raise # permanent: fail now
if attempt == attempts:
log.error("%s: giving up after %d attempts over %.0f s: %s",
fn.__name__, attempts, time.monotonic() - start, exc)
raise
delay = base_delay * 2 ** (attempt - 1)
delay += random.uniform(0, delay * 0.1) # jitter
log.warning("%s: %s; retry %d/%d in %.2f s", fn.__name__, exc, attempt, attempts - 1, delay)
time.sleep(delay)
def read_remote(url, timeout=(3, 30)):
response = requests.get(url, timeout=timeout) # (connect, read) seconds
response.raise_for_status()
return gpd.read_file(io.BytesIO(response.content))
boundary = with_retry(read_remote, f"{BASE}/flaky/2/rutland.geojson")
print(boundary["LAD24NM"].iloc[0], "read after", hits["/flaky/2/rutland.geojson"], "requests")
WARNING read_remote: 503 Server Error: Service Unavailable for url: …/flaky/2/rutland.geojson; retry 1/3 in 0.52 s
WARNING read_remote: 503 Server Error: Service Unavailable for url: …/flaky/2/rutland.geojson; retry 2/3 in 1.02 s
Rutland read after 3 requests
The important part is not the loop — it is retry_if. Retrying everything is a bug, not a feature, and as the next section shows, the obvious exception types do not mean what they appear to.
Transient or permanent, back off, always time out
Sort failures into transient and permanent
This is the decision the whole design rests on. A transient failure might succeed if you try again in a moment: a timeout, a dropped connection, a locked file, an HTTP 503. A permanent failure will fail identically forever: a missing file, a malformed geometry, an unknown CRS, an HTTP 404. Retrying a permanent failure wastes time and hides the real error behind three copies of itself.
The trap is that exception types do not line up with that split. Here are six real failures, and what each one is an instance of:
def locked_file():
holder = open("data/lockfile", "w")
fcntl.flock(holder, fcntl.LOCK_EX) # another process holds the lock
with open("data/lockfile", "w") as mine:
fcntl.flock(mine, fcntl.LOCK_EX | fcntl.LOCK_NB) # we try without waiting
cases = {
"HTTP 503": lambda: requests.get(f"{BASE}/flaky/9/a.geojson", timeout=3).raise_for_status(),
"HTTP 404": lambda: requests.get(f"{BASE}/missing/a.geojson", timeout=3).raise_for_status(),
"refused": lambda: requests.get(DEAD, timeout=3),
"read timeout": lambda: requests.get(f"{BASE}/hang/a.geojson", timeout=(3, 1)),
"missing file": lambda: gpd.read_file("data/no_such_layer.gpkg"),
"locked file": locked_file,
}
print(f"{'failure':13} {'exception':22} {'builtin ConnectionError/TimeoutError':>37} {'OSError':>8} {'is_transient':>13}")
for name, call in cases.items():
try:
call()
except Exception as exc:
builtin = isinstance(exc, (ConnectionError, TimeoutError))
print(f"{name:13} {type(exc).__name__:22} {builtin!s:>37} {isinstance(exc, OSError)!s:>8} {is_transient(exc)!s:>13}")
failure exception builtin ConnectionError/TimeoutError OSError is_transient
HTTP 503 HTTPError False True True
HTTP 404 HTTPError False True False
refused ConnectionError False True True
read timeout ReadTimeout False True True
missing file DataSourceError False False False
locked file BlockingIOError False True True
Three things in that table break the obvious approach:
requests' exceptions are not Python's.requests.ConnectionErrorandrequests.Timeoutare not the built-inConnectionErrorandTimeoutError, soretry_on=(TimeoutError, ConnectionError)— the textbook tuple — retries neither a refused connection nor a timeout fromrequests.OSErroris far too wide. Everyrequestsexception is anOSError, including the one raised for a 404, so addingOSErrorto "catch the network errors" retries permanent failures.- GDAL errors carry no type information. A missing file read through GeoPandas raises pyogrio's
DataSourceError, aRuntimeError, and so does a failed HTTP read through GDAL — whatever the status code. You can only tell them apart by the message.
That is why the quick answer decides with a function: status codes for HTTP, the specific transient classes otherwise. For HTTP, retry 408, 429, 500, 502, 503 and 504; never retry 400, 401, 403 or 404. A lock held by another process raises BlockingIOError on Linux and macOS when you ask not to wait; on Windows, a file another program has open typically fails with a PermissionError (a sharing violation), so there a lock and a permissions problem share a type too.
Back off exponentially, and add jitter
Retrying immediately usually fails again — whatever was overloaded a millisecond ago still is. Doubling the wait after each attempt gives the other side time to recover, and a small random addition (jitter) stops multiple parallel workers from retrying in lockstep and re-creating the spike that caused the failure.
for attempt in range(1, 5):
delay = 1.0 * 2 ** (attempt - 1) # 1 s, 2 s, 4 s, 8 s
print(f"attempt {attempt}: wait {delay:.0f} s + up to {delay * 0.1:.1f} s of jitter")
attempt 1: wait 1 s + up to 0.1 s of jitter
attempt 2: wait 2 s + up to 0.2 s of jitter
attempt 3: wait 4 s + up to 0.4 s of jitter
attempt 4: wait 8 s + up to 0.8 s of jitter
Cap the total: three waits of 1, 2 and 4 seconds — about seven seconds over four attempts — is right for most GIS jobs. If the service is genuinely down, more attempts only delay the alert.
Always pass a timeout
An unbounded call is a worse failure than a crash, because nothing tells you it is stuck. requests never times out unless told to; pair a short connect timeout with a longer read timeout for the large payloads GIS work involves.
start = time.monotonic()
try:
requests.get(f"{BASE}/hang/b.geojson", timeout=(3, 2)) # 3 s to connect, 2 s between bytes
except requests.Timeout as exc:
print(type(exc).__name__, "after", round(time.monotonic() - start), "s")
ReadTimeout after 2 s
The read timeout is the time allowed between bytes, not for the whole download: a server that trickles one byte a second never trips it. Where a total deadline matters, enforce it outside the call, as example 3 does.
The database needs the same bounds — one on connecting and one on any single statement:
engine = create_engine("postgresql+psycopg://user@localhost/gis",
connect_args={"connect_timeout": 10, "options": "-c statement_timeout=2000"})
start = time.monotonic()
try:
with engine.connect() as conn:
conn.execute(text("SELECT pg_sleep(30)")) # a query that would run 30 s
except Exception as exc:
print(type(exc).__name__, "after", round(time.monotonic() - start), "s:", str(exc.orig).splitlines()[0])
start = time.monotonic()
try:
create_engine("postgresql+psycopg://user@10.255.255.1/gis", # an address that never answers
connect_args={"connect_timeout": 3}).connect()
except Exception as exc:
print(type(exc).__name__, "after", round(time.monotonic() - start), "s")
OperationalError after 2 s: canceling statement due to statement timeout
OperationalError after 3 s
PostgreSQL cancelled the query at two seconds, and the connection to an address that never answers gave up at three. Without connect_timeout, that connection waits on the operating system's TCP timeout, which is minutes.
Know which library is doing the HTTP
gpd.read_file() with a URL does not simply hand it to GDAL. GeoPandas first probes the server with Python's urllib — which has no timeout — and then either passes the URL on to GDAL or downloads the whole file itself with urllib, again with no timeout. So GDAL's HTTP settings may not apply at all:
pyogrio.set_gdal_config_options({"GDAL_HTTP_MAX_RETRY": "3", "GDAL_HTTP_RETRY_DELAY": "1",
"GDAL_HTTP_TIMEOUT": "3"})
for label, url in (("plain URL", f"{BASE}/flaky/2/c.geojson"), ("/vsicurl/ URL", f"/vsicurl/{BASE}/flaky/2/d.geojson")):
start = time.monotonic()
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore") # GDAL reports each retry as a warning
layer = gpd.read_file(url)
print(f"{label:14} read {len(layer)} feature in {time.monotonic() - start:.1f} s")
except Exception as exc:
print(f"{label:14} {type(exc).__name__}: {exc}")
plain URL HTTPError: HTTP Error 503: Service Unavailable
/vsicurl/ URL read 1 feature in 3.5 s
The plain URL failed on the first 503: that error came from urllib, and GDAL's retry setting never saw the request. Prefixed with /vsicurl/, GDAL itself made the requests, honoured GDAL_HTTP_MAX_RETRY, and read the layer after two retries. Use /vsicurl/ when you want GDAL's settings to govern the read — and set them with pyogrio.set_gdal_config_options() or in the environment before Python starts.
GDAL's own retries stack with its timeout. Against the server that never answers:
start = time.monotonic()
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
gpd.read_file(f"/vsicurl/{BASE}/hang/e.geojson")
except Exception as exc:
print(f"{type(exc).__name__} after {time.monotonic() - start:.0f} s and {hits['/hang/e.geojson']} requests: "
f"{str(exc)[:60]}")
pyogrio.set_gdal_config_options({"GDAL_HTTP_MAX_RETRY": None, "GDAL_HTTP_RETRY_DELAY": None, "GDAL_HTTP_TIMEOUT": None})
DataSourceError after 64 s and 12 requests: CURL error: Operation timed out after 3002 milliseconds with
A three-second timeout, and the read took about a minute: GDAL retried the timed-out requests, and /vsicurl/ makes several requests to open one file. GDAL_HTTP_TIMEOUT bounds each request, not the read. And if you wrap that read in your own three-attempt retry as well, the worst case multiplies again.
Wrap the call, not the pipeline
Retry the smallest thing that can fail on its own. Retrying a whole stage re-runs work that already succeeded and, if that stage writes files, can leave half-written output behind. The right unit is one external call:
def extract(url):
layer = with_retry(read_remote, url) # just the read is retried
return layer.to_crs(27700) # never retried
print(round(extract(f"{BASE}/flaky/1/f.geojson").area.iloc[0] / 1e6, 1), "km²")
WARNING read_remote: 503 Server Error: Service Unavailable for url: …/flaky/1/f.geojson; retry 1/3 in 0.53 s
393.7 km²
Make retried operations safe to repeat
A retry runs the operation again, so anything with a side effect must be safe to repeat. Write to a temporary file and rename it into place once complete: a rename is atomic on the same filesystem, so a reader never sees a partial file and a retry never appends to a corrupt one.
def write_atomic(gdf, out_path: Path, driver="GPKG"):
tmp = out_path.with_name(f"{out_path.stem}-partial{out_path.suffix}") # same folder, same extension
gdf.to_file(tmp, driver=driver, layer=out_path.stem) # name the layer for the final file
tmp.replace(out_path) # atomic on the same filesystem
Path("output").mkdir(exist_ok=True)
write_atomic(boundary, Path("output/rutland.gpkg"))
print(sorted(p.name for p in Path("output").iterdir()), gpd.read_file("output/rutland.gpkg").crs)
['rutland.gpkg'] EPSG:4326
Two details matter. Keep the temporary file's extension, because GDAL picks a driver from it. And name the layer: without layer=, GDAL names it after the file, so the renamed GeoPackage would contain a layer called rutland-partial — and a name like .rutland.tmp is refused outright ("The layer name may not contain special characters or spaces"), as the first version of this function found.
Use a library when the rules get complicated
Hand-rolled retry is fine for one or two call sites. Past that, tenacity expresses the policy declaratively and is well tested — and it takes a predicate too:
from tenacity import (RetryError, before_sleep_log, retry, retry_if_exception, stop_after_attempt,
wait_exponential_jitter)
@retry(stop=stop_after_attempt(4), wait=wait_exponential_jitter(initial=0.5, max=5),
retry=retry_if_exception(is_transient), before_sleep=before_sleep_log(log, logging.WARNING),
reraise=True)
def fetch_layer(url):
return read_remote(url)
print(len(fetch_layer(f"{BASE}/flaky/1/g.geojson")), "feature")
without_reraise = retry(stop=stop_after_attempt(2), wait=wait_exponential_jitter(initial=0.1),
retry=retry_if_exception(is_transient))(read_remote)
try:
without_reraise(f"{BASE}/flaky/9/h.geojson")
except RetryError as exc:
print("without reraise=True:", type(exc).__name__, "| the real error:",
str(exc.last_attempt.exception()).replace(BASE, "…"))
WARNING Retrying __guide__.fetch_layer in 0.572 seconds as it raised HTTPError: 503 Server Error: Service Unavailable for url: …/flaky/1/g.geojson.
1 feature
without reraise=True: RetryError | the real error: 503 Server Error: Service Unavailable for url: …/flaky/9/h.geojson
reraise=True matters: without it, tenacity raises its own RetryError and your caller's except requests.HTTPError never fires.
Give up loudly
The last attempt should produce a clear, actionable failure — which call, how many attempts, over what period, and the underlying error. A retry policy that ends in a bare traceback has only made the failure later, not clearer:
try:
with_retry(read_remote, f"{BASE}/flaky/9/i.geojson", attempts=3, base_delay=0.2)
except requests.HTTPError:
pass
WARNING read_remote: 503 Server Error: Service Unavailable for url: …/flaky/9/i.geojson; retry 1/2 in 0.20 s
WARNING read_remote: 503 Server Error: Service Unavailable for url: …/flaky/9/i.geojson; retry 2/2 in 0.42 s
ERROR read_remote: giving up after 3 attempts over 1 s: 503 Server Error: Service Unavailable for url: …/flaky/9/i.geojson
Retrying a deliberately flaky map service
The data used on this page
The Rutland boundary from the ONS December 2024 local authority boundaries (OGL), served by a local stand-in for a flaky map service. /flaky/N/… answers 503 to its first N requests, /ratelimit/… answers 429 once with Retry-After: 2, /missing/… answers 404, and /hang/… accepts the connection and sends nothing for 20 seconds. DEAD is a port with nothing listening. The PostGIS examples start a throwaway container, as other guides on this site do.
import fcntl
import io
import logging
import os
import random
import re
import shutil
import socket
import sys
import threading
import time
import warnings
from collections import Counter
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import geopandas as gpd
import pyogrio
import requests
from sqlalchemy import create_engine, text
Path("data").mkdir(exist_ok=True)
shutil.rmtree("output", ignore_errors=True)
if not Path("data/rutland_boundary.geojson").exists():
Path("data/rutland_boundary.geojson").write_bytes(requests.get(
"https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Local_Authority_Districts_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
params={"where": "LAD24NM='Rutland'", "outFields": "LAD24NM", "outSR": 4326, "f": "geojson"}, timeout=120).content)
BODY = Path("data/rutland_boundary.geojson").read_bytes()
hits = Counter()
class FlakyService(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def reply(self, status, body=b"", headers=()):
self.send_response(status)
for key, value in headers:
self.send_header(key, value)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command == "GET":
self.wfile.write(body)
def respond(self):
hits[self.path] += 1
kind, *rest = self.path.strip("/").split("/")
if kind == "flaky" and hits[self.path] <= int(rest[0]):
return self.reply(503)
if kind == "ratelimit" and hits[self.path] == 1:
return self.reply(429, headers=[("Retry-After", "2")])
if kind == "missing":
return self.reply(404)
if kind == "hang":
time.sleep(20)
self.close_connection = True
return
found = re.match(r"bytes=(\d+)-(\d*)", self.headers.get("Range", "")) # serve ranges, like S3 or nginx
if found:
first = int(found.group(1))
last = min(int(found.group(2) or len(BODY) - 1), len(BODY) - 1)
return self.reply(206, BODY[first:last + 1], [("Content-Range", f"bytes {first}-{last}/{len(BODY)}"),
("Accept-Ranges", "bytes")])
return self.reply(200, BODY, [("Content-Type", "application/geo+json"), ("Accept-Ranges", "bytes")])
do_GET = do_HEAD = respond
server = ThreadingHTTPServer(("127.0.0.1", 0), FlakyService)
server.daemon_threads = True
threading.Thread(target=server.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{server.server_address[1]}"
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
DEAD = f"http://127.0.0.1:{probe.getsockname()[1]}/layer.geojson" # nothing listens here
class PrintHandler(logging.Handler): # log lines appear with the page's output
def emit(self, record):
print(re.sub(r"http://127\.0\.0\.1:\d+|host='127\.0\.0\.1', port=\d+", "…", self.format(record)))
handler = PrintHandler()
handler.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
logging.basicConfig(handlers=[handler], force=True)
log = logging.getLogger("retry")
random.seed(7) # makes this page's jitter repeatable
os.environ.update(PGPORT="55433", PGPASSWORD="guide")
if os.system("docker inspect guide-postgis >/dev/null 2>&1") != 0:
os.system("docker run -d --name guide-postgis -e POSTGRES_USER=user -e POSTGRES_PASSWORD=guide "
"-e POSTGRES_DB=gis -p 127.0.0.1:55433:5432 postgis/postgis:16-3.4 >/dev/null")
time.sleep(10)
print("serving", len(BODY), "bytes of", gpd.read_file(io.BytesIO(BODY))["LAD24NM"].iloc[0])
serving 482303 bytes of Rutland
Example 1: A reusable decorator
Same logic as the function form, applied at the definition site so every call is protected.
import functools
def retrying(attempts=4, base_delay=0.5, retry_if=is_transient):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return with_retry(fn, *args, attempts=attempts, base_delay=base_delay, retry_if=retry_if, **kwargs)
return wrapper
return decorator
@retrying(attempts=5, base_delay=0.2)
def fetch_features(url):
return read_remote(url)
print(len(fetch_features(f"{BASE}/flaky/3/j.geojson")), "feature after", hits["/flaky/3/j.geojson"], "requests")
WARNING fetch_features: 503 Server Error: Service Unavailable for url: …/flaky/3/j.geojson; retry 1/4 in 0.20 s
WARNING fetch_features: 503 Server Error: Service Unavailable for url: …/flaky/3/j.geojson; retry 2/4 in 0.42 s
WARNING fetch_features: 503 Server Error: Service Unavailable for url: …/flaky/3/j.geojson; retry 3/4 in 0.81 s
1 feature after 4 requests
Example 2: Retrying HTTP by status code, honouring Retry-After
requests does not raise on a 503 until you ask it to, so decide by status — and on a 429, wait as long as the server says.
def get_with_retry(url, attempts=4, timeout=(3, 30), **kwargs):
for attempt in range(1, attempts + 1):
try:
resp = requests.get(url, timeout=timeout, **kwargs)
if resp.status_code in RETRY_STATUS and attempt < attempts:
wait = float(resp.headers.get("Retry-After", 2 ** (attempt - 1)))
print(f" {resp.status_code}: waiting {wait:.0f} s")
time.sleep(wait + random.uniform(0, 0.5))
continue
resp.raise_for_status() # 4xx → raises, not retried
return resp
except (requests.Timeout, requests.ConnectionError):
if attempt == attempts:
raise
time.sleep(2 ** (attempt - 1) + random.uniform(0, 0.5))
print(get_with_retry(f"{BASE}/ratelimit/k.geojson").status_code)
try:
get_with_retry(f"{BASE}/missing/l.geojson")
except requests.HTTPError as exc:
print("404 raised at once:", exc.response.status_code, "after", hits["/missing/l.geojson"], "request")
429: waiting 2 s
200
404 raised at once: 404 after 1 request
Honouring Retry-After on a 429 is the difference between being rate-limited and being blocked.
Example 3: A hard deadline around code that does not accept one
Some calls — gpd.read_file() on a plain URL, a GDAL driver, a long geometry operation — offer no deadline for the whole operation. Run them in a separate process and kill the process if it overruns:
from concurrent.futures import ProcessPoolExecutor, TimeoutError as FutureTimeout
Path("deadline_worker.py").write_text('''import geopandas as gpd
def read_layer(url):
return len(gpd.read_file(url))
''') # a process pool needs its worker function in an importable module
from deadline_worker import read_layer
def read_with_deadline(url, seconds):
pool = ProcessPoolExecutor(max_workers=1)
future = pool.submit(read_layer, url)
try:
return future.result(timeout=seconds)
except FutureTimeout:
for proc in pool._processes.values(): # private, but there is no public kill
proc.terminate()
raise TimeoutError(f"reading {url} exceeded {seconds} s") from None
finally:
pool.shutdown(wait=False, cancel_futures=True)
start = time.monotonic()
try:
read_with_deadline(f"{BASE}/hang/m.geojson", seconds=4)
except TimeoutError as exc:
print(exc.args[0].replace(BASE, "…"), "| returned after", round(time.monotonic() - start), "s")
print("and a healthy read:", read_with_deadline(f"{BASE}/ok/n.geojson", seconds=30), "feature")
reading …/hang/m.geojson exceeded 4 s | returned after 4 s
and a healthy read: 1 feature
The worker function lives in a module because the pool's processes import it by name: a function defined in a notebook cell or in __main__ of an interactive session cannot be sent to a process started with spawn or forkserver — the default on Linux from Python 3.14, and on Windows and macOS already. A thread cannot be killed in Python, so a separate process is the only reliable way to enforce a deadline on blocking C code — or on the urllib call inside read_file, which would otherwise have waited out the server's full 20 seconds, twice. The TimeoutError it raises is the built-in one, so is_transient retries it.
Example 4: Per-file retries inside a batch loop
In a batch job, a retry belongs around each file, and a final failure should skip that file rather than end the run.
summary = []
for name in ("flaky/1/p.geojson", "missing/q.geojson", "ok/r.geojson", "flaky/9/s.geojson"):
try:
layer = with_retry(read_remote, f"{BASE}/{name}", attempts=3, base_delay=0.2)
write_atomic(layer, Path("output") / name.replace("/", "_").replace(".geojson", ".gpkg"))
summary.append((name, "ok"))
except Exception as exc:
summary.append((name, f"failed: {type(exc).__name__} {getattr(getattr(exc, 'response', None), 'status_code', '')}"))
for row in summary:
print(*row)
WARNING read_remote: 503 Server Error: Service Unavailable for url: …/flaky/1/p.geojson; retry 1/2 in 0.21 s
WARNING read_remote: 503 Server Error: Service Unavailable for url: …/flaky/9/s.geojson; retry 1/2 in 0.22 s
WARNING read_remote: 503 Server Error: Service Unavailable for url: …/flaky/9/s.geojson; retry 2/2 in 0.40 s
ERROR read_remote: giving up after 3 attempts over 1 s: 503 Server Error: Service Unavailable for url: …/flaky/9/s.geojson
flaky/1/p.geojson ok
missing/q.geojson failed: HTTPError 404
ok/r.geojson ok
flaky/9/s.geojson failed: HTTPError 503
The retry handled the blip; the try/except handled the file that was genuinely missing and the one that stayed down. The 404 was reported without a single retry. See batch process a folder of GIS files.
Example 5: A circuit breaker for a service that is simply down
When a service is down, retrying every file wastes the whole run. Stop after enough consecutive failures:
class CircuitBreaker:
def __init__(self, threshold=3):
self.threshold, self.failures = threshold, 0
def call(self, fn, *args, **kwargs):
if self.failures >= self.threshold:
raise RuntimeError(f"circuit open: {self.failures} consecutive failures — service is down")
try:
result = fn(*args, **kwargs)
except Exception:
self.failures += 1
raise
self.failures = 0
return result
breaker = CircuitBreaker(threshold=3)
start = time.monotonic()
for tile in range(10):
try:
breaker.call(with_retry, read_remote, DEAD, attempts=2, base_delay=0.2)
except RuntimeError as exc:
print(f"tile {tile}: {exc}")
break
except requests.ConnectionError:
print(f"tile {tile}: connection refused (after retries)")
print(f"stopped after {time.monotonic() - start:.1f} s instead of trying all 10 tiles")
WARNING read_remote: HTTPConnectionPool(…): Max retries exceeded with url: /layer.geojson (Caused by NewConnectionError("HTTPConnection(…): Failed to establish a new connection: [Errno 111] Connection refused")); retry 1/1 in 0.20 s
ERROR read_remote: giving up after 2 attempts over 0 s: HTTPConnectionPool(…): Max retries exceeded with url: /layer.geojson (Caused by NewConnectionError("HTTPConnection(…): Failed to establish a new connection: [Errno 111] Connection refused"))
tile 0: connection refused (after retries)
WARNING read_remote: HTTPConnectionPool(…): Max retries exceeded with url: /layer.geojson (Caused by NewConnectionError("HTTPConnection(…): Failed to establish a new connection: [Errno 111] Connection refused")); retry 1/1 in 0.21 s
ERROR read_remote: giving up after 2 attempts over 0 s: HTTPConnectionPool(…): Max retries exceeded with url: /layer.geojson (Caused by NewConnectionError("HTTPConnection(…): Failed to establish a new connection: [Errno 111] Connection refused"))
tile 1: connection refused (after retries)
WARNING read_remote: HTTPConnectionPool(…): Max retries exceeded with url: /layer.geojson (Caused by NewConnectionError("HTTPConnection(…): Failed to establish a new connection: [Errno 111] Connection refused")); retry 1/1 in 0.22 s
ERROR read_remote: giving up after 2 attempts over 0 s: HTTPConnectionPool(…): Max retries exceeded with url: /layer.geojson (Caused by NewConnectionError("HTTPConnection(…): Failed to establish a new connection: [Errno 111] Connection refused"))
tile 2: connection refused (after retries)
tile 3: circuit open: 3 consecutive failures — service is down
stopped after 0.6 s instead of trying all 10 tiles
Retries bet that the world will change
Retries are a bet that the world will be different in a second. That bet pays off for anything crossing a network or a filesystem boundary, and never pays off for logic errors or bad data. Splitting failures into transient and permanent is therefore not a refinement — it is the design. A job that retries a missing file four times takes seven extra seconds to deliver exactly the same message, and it teaches whoever reads the log to distrust retry warnings entirely. The split has to be made on what the error means, because, as the table above showed, the exception classes do not make it for you.
Exponential backoff exists because the most common cause of a transient failure is load, and immediate retries add load. Doubling the delay gives the other side room; jitter prevents synchronised herds of retries from re-creating the spike. Both matter more as soon as you run work in parallel, where a dozen workers can hammer a struggling server simultaneously.
Timeouts are the more important half of this article, and the one more often skipped. A retry policy without timeouts is not a safety net — a single hung connection can hold a job open indefinitely, past the next scheduled run, until someone notices the following week. The rule is absolute: every call that leaves the process gets a deadline, and you must know which library is actually making the call. gpd.read_file() on a URL made its first request with urllib, which no GDAL setting reaches. Where nothing offers a deadline, put the work in a subprocess you can kill, as in Example 3.
Idempotence makes retries safe. Because a retried operation may run more than once, any side effect must tolerate repetition: write to a temporary file and rename, use a transaction and commit once, or derive the output name from the input so a repeat overwrites its own work rather than appending. Get this wrong and a retry produces a half-written GeoPackage or a table with duplicated rows — a worse outcome than the original failure. The same reasoning underpins scheduling, where a job may be run twice by a scheduler that catches up after downtime.
Finally, retries hide problems by design, so make sure they are visible. Log every retry at warning level with the reason, and count them. A job that quietly retries twice on every run is telling you something about the service it depends on, and that information is worth more than the seconds the retries saved.
Half-written files, workers and time budgets
Retrying a partially written file
If the first attempt failed halfway through writing, the second may append to or trip over the debris. Always write to a temporary path and replace() it into place. Path.replace() is atomic within one filesystem, so a reader sees either the old file or the new one, never a half.
Retries inside parallel workers
Each worker retries independently, so a service already under strain receives workers × attempts requests. Jitter helps; a shared circuit breaker helps more. When throughput matters more than latency, reduce the worker count rather than the retry count.
requests does not raise on HTTP errors
requests.get() returns normally for a 500 or a 503, so a naïve try/except around it retries nothing. Either check resp.status_code explicitly or call resp.raise_for_status() — and remember that turns 4xx into exceptions too, which you should not retry.
Timeouts through GDAL
gpd.read_file() on a plain URL makes its first request with Python's urllib, which has no timeout and ignores every GDAL setting. Prefix the URL with /vsicurl/ to have GDAL make the requests, then configure GDAL_HTTP_TIMEOUT, GDAL_HTTP_CONNECTTIMEOUT, GDAL_HTTP_MAX_RETRY and GDAL_HTTP_RETRY_DELAY with pyogrio.set_gdal_config_options() or in the environment before Python starts. The timeout is per request and GDAL retries timed-out requests, so a three-second timeout took about a minute to give up on a server that never answered.
Retrying a database write
A retried INSERT can duplicate rows if the first attempt actually succeeded and only the acknowledgement was lost. Wrap writes in an explicit transaction, or make them idempotent with a unique key and an upsert. This is the one case where a retry can silently corrupt data rather than merely waste time.
Total time budget
Four attempts at 1, 2, 4, and 8 seconds is fifteen seconds — fine. The same policy applied per file across 500 files, with a service that is down, is over two hours of sleeping. Bound the job, not just each call: track elapsed time and abort the run when a budget is exhausted.
Internal links
- How to Build a GIS Data Pipeline in Python
- Schedule a Python GIS Script to Run Automatically
- Get Alerted When an Automated GIS Job Fails
- Validate Pipeline Inputs and Outputs Automatically
- Batch Process a Folder of GIS Files in Python
- Speed Up Batch GIS Jobs with Parallel Processing
- Log and Summarise Errors in a Batch GIS Job
- How to Connect GeoPandas to PostGIS
FAQ
Which errors should I retry in a GIS job?
Only those a second attempt could plausibly fix: timeouts, connection resets, temporarily locked files, and HTTP 429/5xx responses. Decide with a function rather than an exception tuple: requests.Timeout is not the built-in TimeoutError, every requests error — a 404 included — is an OSError, and GDAL raises the same RuntimeError subclass for a missing file and a failed download. Never retry missing files, permission errors, invalid CRS codes, malformed geometry, or HTTP 4xx — they will fail identically every time, and retrying them delays the real error message.
How many retries and how long should I wait?
Three or four attempts with exponential backoff starting at one second covers nearly all transient failures — roughly seven to fifteen seconds total. Beyond that you are usually waiting on something genuinely down, where failing quickly and alerting is more useful than continuing to wait.
What is jitter and do I need it?
Jitter is a small random addition to each backoff delay. It matters when several workers fail at the same moment: without it they all retry at exactly the same instant and re-create the overload. With parallel processing or a shared service, add it; for a single-threaded job it is harmless insurance.
How do I put a timeout on gpd.read_file() over HTTP?
Not through GDAL settings alone: on a plain URL, GeoPandas makes its first request with urllib, which has no timeout. Either download the bytes with requests.get(url, timeout=(3, 30)) and pass io.BytesIO(response.content) to read_file, or prefix the URL with /vsicurl/ so GDAL makes every request and GDAL_HTTP_TIMEOUT applies — per request, with GDAL's own retries on top. For a hard deadline on the whole read, run it in a separate process and terminate it if it overruns.
Is it safe to retry a write?
Only if the write is idempotent. Write to a temporary file and replace() it into the final path so a repeat cannot leave partial output. For databases, use a transaction or an upsert with a unique key; a plain retried INSERT can duplicate rows when the first attempt succeeded but its acknowledgement was lost.
Should I retry the whole pipeline stage or just the failing call?
Just the call. Retrying a stage repeats work that already succeeded, can double side effects, and makes the log harder to read. Wrap the single external operation — one HTTP request, one file read, one query — and let everything around it stay simple.
How do I stop retries from hiding a service that is down?
Log every retry at warning level and count them, so a rising retry rate is visible before it becomes an outage. Add a circuit breaker that stops trying after several consecutive failures, and make the final failure message say how many attempts were made over what period. Then alert on it — a job that silently retried for an hour tells nobody anything.