Implementing Error Handling & Retry Logic in Geospatial Schema Mapping Pipelines Jump to heading

Geospatial ETL workflows operating at municipal or federal scale require deterministic failure management. When automating attribute standardization across heterogeneous sources, transient network faults, malformed geometries, or schema drift will inevitably interrupt batch processing. Error handling and retry logic must be engineered as a first-class pipeline component rather than an afterthought. This stage operates at the core of Automated Attribute Transformation & ETL Workflows, wrapping every transformation step with fault-tolerant routing, tolerance thresholds, and compliance-grade audit trails.

This page covers the resilience layer that surrounds attribute transformation: classifying errors, retrying transient faults, and quarantining records that cannot be recovered. It does not implement the transformations themselves — type casting belongs to Field Renaming & Type Coercion Rules, the recursive traversal of structured payloads belongs to Nested JSON/GeoJSON Flattening, and the streaming execution loop these policies wrap is described in Batch Schema Processing Pipelines. Reprojection faults raised here are recovered upstream by CRS Normalization & Sync.

Deterministic error-routing map for a retried geospatial record A record is processed, then any exception is classified. Terminal data-shape faults such as CRSError or ArrowInvalid fail fast and are quarantined without retry. Transient faults such as ProjError or HTTP 429/502/503 enter an exponential-backoff loop that re-enqueues the record back to Process; on success the record reaches validated output, and once the attempt budget is exhausted it is routed to the dead-letter queue. Every path ends in exactly one deterministic action. Process Record Classify exception Fail Fast & Quarantine CRSError, ArrowInvalid, TopologyException Backoff & Retry ProjError, HTTP 429 / 502 / 503 delay = base · 2ⁿ + jitter Validated Output idempotent UPSERT Dead-Letter Queue budget exhausted → quarantine terminal transient success re-enqueue (attempt < max) budget exhausted no error → straight through

Declarative Retry & Tolerance Manifest Jump to heading

Resilient schema mapping engines drive failure behaviour from externalized manifests rather than hard-coded constants. By isolating retry parameters, timeout windows, tolerance thresholds, and fallback destinations into YAML, engineering teams achieve reproducible execution across staging and production and can review a policy change as a diff. The manifest must enforce strict typing and explicitly distinguish mandatory from optional fields so that a typo fails loudly at load time instead of silently widening the failure budget in production.

yaml
# pipeline_resilience.yaml — validated at startup against the pydantic model below
pipeline:
  id: "municipal-parcel-sync"
  retry_policy:
    max_attempts: 3              # MANDATORY: int >= 1. Hard stop threshold.
    base_delay_seconds: 2.0      # MANDATORY: float > 0. Initial backoff window.
    jitter: true                 # OPTIONAL: bool. Defaults to true. Prevents thundering herd.
    retryable_status_codes:      # MANDATORY: list[int]. Transient fault identifiers.
      - 429
      - 502
      - 503
    terminal_status_codes:       # OPTIONAL: list[int]. Defaults to [400, 401, 403, 404, 500].
      - 400
      - 401
  tolerance:
    max_null_rate: 0.005         # MANDATORY: float 0.0-1.0. Acceptable missing-data threshold.
    halt_on_geometry_invalid: true   # MANDATORY: bool. Enforces spatial integrity.
    precision_decimal_threshold: 6   # OPTIONAL: int. Defaults to 6. Coordinate precision floor.
  dead_letter:
    sink_uri: "s3://gis-quarantine/parcel-sync/"  # MANDATORY: str. Durable failure store.
    retain_days: 90              # OPTIONAL: int. Defaults to 90. Governance retention window.
Field Required Type / range Default Purpose
retry_policy.max_attempts Mandatory int >= 1 Hard ceiling on reprocessing of a single record.
retry_policy.base_delay_seconds Mandatory float > 0 First backoff window; doubled each attempt.
retry_policy.jitter Optional bool true Randomizes delay to avoid synchronized retry storms.
retry_policy.retryable_status_codes Mandatory list[int] Faults eligible for retry; everything else is terminal.
retry_policy.terminal_status_codes Optional list[int] [400,401,403,404,500] Faults that must fail fast without retry.
tolerance.max_null_rate Mandatory float 0.0–1.0 Batch-level null-injection ceiling before rejection.
tolerance.halt_on_geometry_invalid Mandatory bool Stops the batch on any invalid geometry.
tolerance.precision_decimal_threshold Optional int 6 Minimum coordinate decimal places accepted.
dead_letter.sink_uri Mandatory str Durable destination for exhausted records.
dead_letter.retain_days Optional int 90 Quarantine retention for audit/reprocessing.

Mandatory fields must be present and validated at pipeline initialization; a missing value triggers an immediate ValidationError before any spatial I/O occurs. Optional fields inherit the documented defaults above, and any override must still pass schema validation so that policy drift cannot creep in unnoticed.

Preprocessing: Normalising the Failure Surface Jump to heading

The hardest part of geospatial retry logic is not the loop — it is that the underlying libraries raise wildly different exception types for conceptually identical faults. A transient PROJ grid-server timeout surfaces as pyproj.exceptions.ProjError, a malformed coordinate as pyproj.exceptions.CRSError, a type-cast overflow as pyarrow.lib.ArrowInvalid, and a self-intersecting ring as a Shapely TopologyException. Before a record enters the retry loop, every one of these must be collapsed into a single classified error carrying a transient flag, so the loop can make a deterministic route/retry decision without library-specific branching.

python
# preprocessing/error_classifier.py
# Python 3.10+, pyproj >=3.6, pyarrow >=14, shapely >=2.0
from dataclasses import dataclass
from pyproj.exceptions import CRSError, ProjError
from pyarrow.lib import ArrowInvalid
from shapely.errors import GEOSException

TRANSIENT_EXC = (ProjError,)            # grid server / network-style faults
TERMINAL_EXC = (CRSError, ArrowInvalid, GEOSException)  # data-shape faults

@dataclass(slots=True)
class ClassifiedError:
    record_id: str
    error_class: str
    transient: bool
    detail: str

def classify(record_id: str, exc: Exception) -> ClassifiedError:
    status = getattr(exc, "status_code", None)
    if status in (429, 502, 503):       # upstream API throttling / gateway
        transient = True
    elif isinstance(exc, TRANSIENT_EXC):
        transient = True
    elif isinstance(exc, TERMINAL_EXC):
        transient = False
    else:
        transient = False               # unknown == terminal; never guess "retry"
    return ClassifiedError(
        record_id=record_id,
        error_class=type(exc).__name__,
        transient=transient,
        detail=str(exc)[:500],
    )

The default-to-terminal rule is deliberate: an unrecognised exception is never retried, because blindly replaying a deterministic data fault wastes the attempt budget and delays quarantine. Records must also arrive flattened and reprojected — if nested attributes or mismatched CRS reach this stage, the resulting exceptions are data-shape faults that no amount of retrying will fix.

Execution Engine & Exponential Backoff Jump to heading

The retry mechanism operates at both the record and batch levels. When a spatial transformation or attribute coercion fails, the engine classifies the exception, increments the attempt counter, and either reschedules reprocessing or escalates to terminal handling. For transient failures — database connection resets, temporary API throttling, grid-server timeouts — implementing exponential backoff in schema mapping jobs ensures retry intervals scale predictably with randomized jitter rather than hammering an already-degraded dependency.

python
# execution/retry_engine.py — Python 3.10+, stdlib only for the core loop
import time
import random
import logging
from typing import Callable, Any
from preprocessing.error_classifier import classify, ClassifiedError

logger = logging.getLogger(__name__)

class RetriesExhausted(Exception):
    def __init__(self, err: ClassifiedError) -> None:
        self.err = err
        super().__init__(f"exhausted retries for {err.record_id}")

def execute_with_backoff(
    func: Callable[[], Any],
    record_id: str,
    max_attempts: int,
    base_delay: float,
    jitter: bool = True,
) -> Any:
    """Run func() with exponential backoff. Raises on terminal faults
    immediately and RetriesExhausted once the attempt budget is spent."""
    for attempt in range(1, max_attempts + 1):
        try:
            return func()
        except Exception as exc:                      # noqa: BLE001 — classified next
            err = classify(record_id, exc)
            if not err.transient:
                logger.error("terminal %s on %s: %s",
                             err.error_class, record_id, err.detail)
                raise                                  # fail fast, no retry
            if attempt == max_attempts:
                logger.error("budget spent (%d) for %s [%s]",
                             max_attempts, record_id, err.error_class)
                raise RetriesExhausted(err) from exc
            delay = base_delay * (2 ** (attempt - 1))
            if jitter:
                delay += random.uniform(0, base_delay)  # decorrelate retries
            logger.warning("transient %s on %s; retry %d/%d in %.2fs",
                           err.error_class, record_id,
                           attempt + 1, max_attempts, delay)
            time.sleep(delay)
Exponential backoff with jitter and the attempt-budget cutoff A timeline of retry delays for a transient fault. The nominal wait doubles each attempt — base delay 2 seconds before retry 2, then 4 seconds before retry 3 — following base times 2 to the n. A shaded jitter band of up to one base delay is added on top of each nominal wait so retries decorrelate. After attempt 3 fails the attempt budget is exhausted: no further retry is scheduled and the record is routed to the dead-letter queue. elapsed time → retry delay (s) 2s 4s attempt 1 fails wait = 2s + jitter attempt 2 fails wait = 4s + jitter jitter band (0 … 1 base delay) attempt 3 fails max_attempts reached Dead-Letter Queue no further retry

There are no silent failures in this loop: a terminal fault propagates immediately, and an exhausted record raises a typed RetriesExhausted carrying the classified error so the caller can route it deterministically. The caller — typically the streaming loop described in Batch Schema Processing Pipelines — catches RetriesExhausted and forwards the payload to the dead-letter sink rather than aborting the whole batch.

Queue-Based Routing & Idempotency Jump to heading

Persistent failures exceeding the configured threshold must be isolated from the primary stream. Durable storage for failed payloads preserves the original geometry, source metadata, and transformation context for manual review or automated reprocessing during off-peak windows. Queue consumers must enforce idempotent write operations so that a successful retry never inserts a duplicate feature: derive a deterministic feature ID such as source_id + sha1(canonical_attributes) and write through an UPSERT against the target geodatabase. Every routing decision — retry, fallback, or quarantine — is emitted as structured JSON for downstream auditability.

Failure Modes & Fallback Routing Jump to heading

Every fault must resolve to exactly one deterministic action. The table below maps the failure types this stage encounters to their cause and recovery route; nothing is swallowed.

Failure type Typical cause Classification Deterministic recovery action
HTTP 429 / 502 / 503 Upstream API throttling or gateway flap Transient Exponential backoff with jitter; retry up to max_attempts, then dead-letter.
ProjError (grid timeout) PROJ datum-grid server unreachable Transient Retry with backoff; on exhaustion route to dead-letter, flag for Datum Transformation Fallback Chains.
CRSError Unparseable or mismatched CRS on the record Terminal Fail fast; quarantine for CRS repair upstream — no retry.
ArrowInvalid Type-coercion overflow or schema mismatch Terminal Quarantine with the offending field name; fix the coercion rule, not the loop.
TopologyException / invalid geometry Self-intersection, null geometry Terminal (batch-halting if halt_on_geometry_invalid) Reject record; halt batch when integrity flag is set.
Null rate over max_null_rate Source-wide attribute gaps Batch-level Reject the whole batch before commit; emit a schema-drift warning.
RetriesExhausted Transient fault that never cleared Terminal after retries Persist full payload to dead_letter.sink_uri for off-peak reprocessing.

Compliance Reporting Output Jump to heading

Compliance-grade pipelines maintain immutable audit trails. For every record that is retried, fall back, or quarantined, the stage appends one structured JSON line to the rejection log using Python’s built-in logging module with a JSON formatter. The minimum lineage fields are the record identifier, the source dataset URI, the classified error_class, the transient flag, the attempt count reached, the final routing destination, and a UTC timestamp.

json
{
  "ts": "2026-06-25T14:02:11Z",
  "pipeline_id": "municipal-parcel-sync",
  "record_id": "parcel:04019:001023",
  "source_uri": "s3://county-ingest/2026Q2/parcels.gpkg",
  "error_class": "ProjError",
  "transient": true,
  "attempts": 3,
  "routing": "dead_letter",
  "sink_uri": "s3://gis-quarantine/parcel-sync/",
  "detail": "PROJ grid server timeout: us_noaa_nadcon"
}

These records satisfy municipal and federal data-governance requirements, give analysts a single queryable surface for root-cause analysis during schema-drift events, and let a reprocessing job replay exactly the quarantined records once the upstream fault clears. The batch-level summary that aggregates these lines — total processed, retried, quarantined, and null-rate observed — is written by the surrounding pipeline and folds into the broader audit output documented in Batch Schema Processing Pipelines.

CI Integration Jump to heading

Resilience logic must be validated before deployment, not discovered in production. Gate the retry manifest and the backoff behaviour with two checks in CI: a schema lint that rejects an out-of-range policy, and a fault-injection simulation that proves transient faults retry and terminal faults fail fast.

yaml
# .github/workflows/pipeline_resilience.yml
name: Validate Pipeline Resilience
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with: { python-version: "3.12" }   # 3.10+ required
      - name: Install dependencies
        run: pip install "pydantic>=2" pytest pyyaml
      - name: Validate retry manifest
        run: |
          python -c "
          import yaml, sys
          from pydantic import BaseModel, Field, ValidationError

          class RetryPolicy(BaseModel):
              max_attempts: int = Field(..., ge=1)
              base_delay_seconds: float = Field(..., gt=0)
              jitter: bool = True
              retryable_status_codes: list[int]

          try:
              cfg = yaml.safe_load(open('pipeline_resilience.yaml'))
              RetryPolicy(**cfg['pipeline']['retry_policy'])
              print('manifest valid')
          except ValidationError as e:
              print(f'config invalid: {e}', file=sys.stderr); sys.exit(1)
          "
      - name: Run fault-injection simulation
        run: pytest tests/test_retry_logic.py -v

A matching pytest fixture asserts the two invariants that matter most: a function raising a retryable status code is invoked exactly max_attempts times before RetriesExhausted, and a function raising a terminal CRSError is invoked exactly once. Wiring these as a required status check means no policy change merges without proving its routing behaviour.

python
# tests/test_retry_logic.py — pytest, Python 3.10+
import pytest
from execution.retry_engine import execute_with_backoff, RetriesExhausted

class Throttled(Exception):
    status_code = 503

def test_transient_exhausts_budget():
    calls = {"n": 0}
    def fn():
        calls["n"] += 1
        raise Throttled()
    with pytest.raises(RetriesExhausted):
        execute_with_backoff(fn, "rec-1", max_attempts=3,
                             base_delay=0.0, jitter=False)
    assert calls["n"] == 3            # retried, not silently dropped

def test_terminal_fails_fast():
    from pyproj.exceptions import CRSError
    calls = {"n": 0}
    def fn():
        calls["n"] += 1
        raise CRSError("unparseable")
    with pytest.raises(CRSError):
        execute_with_backoff(fn, "rec-2", max_attempts=3,
                             base_delay=0.0, jitter=False)
    assert calls["n"] == 1            # no wasted retries on a data fault

Frequently Asked Questions Jump to heading

Why default an unrecognised exception to terminal rather than retrying it? Most faults that crash a geospatial transform are deterministic data-shape problems — an unparseable CRS string, a self-intersecting ring, a type-cast overflow — and replaying them simply burns the attempt budget and delays quarantine. The classifier only marks a fault transient when it matches a known network-style signature (an upstream 429/502/503, or a ProjError from a grid server). Everything else fails fast, so a genuinely broken record reaches the dead-letter sink in one attempt instead of three.

How do retries avoid creating duplicate features in the target geodatabase? The write path is idempotent by construction. Each feature carries a deterministic ID derived from source_id + sha1(canonical_attributes), and the consumer writes through an UPSERT rather than an INSERT. A retry that succeeds on its second or third attempt resolves to the same ID and overwrites the partial row instead of appending a second copy — so a flapping connection can never inflate the feature count.

Should backoff jitter ever be disabled? Only in tests, where deterministic timing makes assertions stable (the fixtures above pass jitter=False). In production keep it on: without jitter, a batch of records that all hit the same throttled PROJ grid server retry in lockstep and re-saturate the dependency the instant it recovers. The randomized component decorrelates those retries. Backoff curve and jitter tuning are covered in depth in implementing exponential backoff in schema mapping jobs.

What is the difference between a record-level reject and a batch-level halt? A record-level reject quarantines a single feature and lets the batch continue — the normal path for a CRSError or an exhausted retry. A batch-level halt stops the whole commit before any write lands, and is reserved for integrity violations: an invalid geometry when halt_on_geometry_invalid is set, or a source-wide null rate over max_null_rate. The distinction keeps one bad parcel from aborting a clean batch while still refusing to publish a structurally corrupt dataset.

Where should a ProjError actually be fixed? Not in the retry loop. A transient ProjError from an unreachable datum-grid server is retried here, but a persistent one means a missing transformation grid, which is recovered upstream by the Datum Transformation Fallback Chains strategy under CRS Normalization & Sync. This stage only routes the fault and records it; the grid repair is a CRS concern.

Deeper Implementation Guides Jump to heading