This guide is part of Orchestrating Spatial Pipelines with Prefect, within the broader Orchestrating Spatial ETL Pipelines reference.

Parallel Tile Downloads with Prefect Task Mapping

The Problem: Unbounded Fan-Out Either Rate-Limits You or Fails on One Bad Tile

Downloading a few thousand map tiles or satellite scenes is embarrassingly parallel — until it meets a rate-limited API and a single corrupt tile. Prefect’s .map() makes the fan-out trivial to express, but the two failure modes it invites are exactly the ones that take down production spatial flows:

  • Fan-out width is not a rate limit. A .map() over 5,000 tile bboxes schedules 5,000 task runs; with no bound, the task runner admits as many as it can and the download endpoint answers with a wall of HTTP 429s, poisoning even the requests that would have succeeded.
  • One failed tile fails everything. By default a raised exception in a mapped run propagates, and the flow aborts — discarding thousands of tiles that downloaded cleanly and forcing a full re-run.
  • Retries amplify the flood. Retrying every 429 without a concurrency ceiling turns a throttle event into a self-reinforcing storm, because the retries pile onto the same saturated endpoint.
  • Silent partial coverage. Without explicit success/failure partitioning, a flow can appear to finish while a subset of tiles never landed, leaving gaps in a mosaic that surface only during analysis.
What a dead-letter record has to contain The anatomy of one dead-letter row. Identity fields name the tile and the run. Failure fields record the exception class, message and attempt count. Reproduction fields hold the exact request URL and parameters. Together they let a replay job re-run precisely the failed units without inspecting logs or guessing at inputs. one row per failed unit of work identity tile_id · T31UEQ run_id · 2026-03-18T02:00 flow_run · 8f3c… answers "which unit" failure exc_type · HTTPStatusError message · 504 after 3 tries attempts · 3 answers "why" reproduction request_url · https://… params · bbox, datetime code_version · v3.2.1 answers "replay how" Without the third column the dead-letter table is a report. With it, it is a work queue a replay flow can consume directly.

The fix is two independent controls: a global concurrency limit that decouples fan-out width from simultaneous execution, and per-tile error isolation that turns a failure into a quarantined record instead of an aborted flow.

Version and Environment Compatibility

Prefect Concurrency control Per-tile isolation Caveat
>=3.0 Named concurrency / global_concurrency_limit plus tag limits return_state=True on .map() Prefer named global limits; states expose .is_completed()
2.14.x Tag-based concurrency-limit only return_state=True on .map() No named global limit context; tag the task instead
<2.8 Tag limits, less reliable under high fan-out return_state=True Upgrade before relying on limits at scale
pip install "prefect>=3.0" "geopandas>=1.0" requests

The diagram below shows how the concurrency limit gates a wide fan-out and where failures branch off to the dead-letter set.

Bounded, fault-isolated tile download fan-out A list of N tile bboxes is mapped into N task runs that queue behind a concurrency limit of K. Only K runs execute simultaneously against the download API. Each settled run yields a state: completed runs feed a results manifest, failed runs feed a dead-letter list, and the flow finishes successfully with a partial result. N queued task runs download(bbox 0) download(bbox 1) download(bbox 2) download(bbox N) concurrency limit = K K run at once rate-limited download API settle state return_state completed results manifest failed dead-letter quarantine list flow finishes green with a partial, audited result — no single tile can abort it

The Recipe: A Bounded, Fault-Isolated Mapped Download

The task downloads one tile bbox and is tagged so a global concurrency limit throttles it. The flow maps it over the runtime tile list with return_state=True, then partitions the settled states into completed and failed. Nothing raises out of the fan-out.

import logging
from dataclasses import dataclass
from pathlib import Path

import requests
from prefect import flow, task, unmapped
from prefect.futures import wait
from prefect.states import State

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class Tile:
    """One unit of download work, keyed by a stable tile id."""
    tile_id: str
    bbox: tuple[float, float, float, float]
    url: str


@task(
    tags=["tile-api"],                      # bound by the concurrency limit below
    retries=3,
    retry_delay_seconds=[5, 20, 60],        # backoff so retries do not amplify a 429 storm
)
def download_tile(tile: Tile, output_root: str) -> str:
    """Download one tile to object storage; return its URI. Idempotent on re-run."""
    dest = Path(output_root) / f"{tile.tile_id}.tif"
    dest.parent.mkdir(parents=True, exist_ok=True)
    if dest.exists():
        return dest.as_uri()                # already present — skip network + a slot

    tmp = dest.with_suffix(".part")
    with requests.get(tile.url, params={"bbox": ",".join(map(str, tile.bbox))},
                      stream=True, timeout=60) as resp:
        resp.raise_for_status()             # a 4xx/5xx raises → this run's state is Failed
        with tmp.open("wb") as fh:
            for chunk in resp.iter_content(chunk_size=1 << 16):
                fh.write(chunk)
    tmp.rename(dest)                         # atomic publish
    return dest.as_uri()


@flow(name="bounded-tile-download")
def download_tiles_flow(tiles: list[Tile], output_root: str) -> dict:
    """Map the download over every tile, bounded by concurrency, isolating failures."""
    # return_state=True → each element is a State, not a raised result.
    states: list[State] = download_tile.map(
        tiles, output_root=unmapped(output_root), return_state=True
    )
    wait(states)

    completed: list[str] = []
    dead_letter: list[str] = []
    for tile, state in zip(tiles, states):
        if state.is_completed():
            completed.append(state.result())          # the returned URI
        else:
            logger.warning("Tile %s failed after retries: %s", tile.tile_id, state.type)
            dead_letter.append(tile.tile_id)           # quarantine, do not raise

    logger.info("Downloaded %d/%d tiles; %d dead-lettered",
                len(completed), len(tiles), len(dead_letter))
    return {"completed": completed, "dead_letter": dead_letter}

Register the concurrency limit once, sized to the API’s rate envelope rather than the worker count:

# At most 8 'tile-api' task runs execute simultaneously, however wide .map() gets.
prefect concurrency-limit create tile-api 8

Key Implementation Notes

  • return_state=True is what converts a failure into data. Without it, the first tile that exhausts its retries raises out of .map() and aborts the flow. With it, each run settles into a State you inspect after wait(), so the flow decides what a failure means instead of the exception dictating it.
  • The concurrency limit throttles execution, not scheduling. All N runs are scheduled immediately; the limit gates how many leave the queue. This is why a 5,000-tile .map() with a limit of 8 is safe — the download rate is governed by K, not N.
  • Size K to the slowest external dependency. The rate-limited endpoint, not the number of workers, is the real constraint. A worker pool can exceed K safely because the concurrency limit is the throttle; setting K above the API’s requests-per-second envelope reintroduces the 429 storm.
  • Retries carry a backoff curve, not a flat delay. A retry_delay_seconds list spaces retries so a transient throttle recovers between attempts, rather than three immediate retries hammering an already-saturated endpoint.
  • A cache hit or an existing file never consumes a slot on the network path. The dest.exists() short-circuit returns before the request, so a mostly-complete re-run clears fast and only the genuinely-missing tiles contend for the limited slots.
  • zip(tiles, states) preserves the tile-to-state mapping. .map() returns states in input order, so pairing each state with its originating tile gives you the tile id to write into the dead-letter list — essential for a targeted re-run of only the failures.
Picking the concurrency limit empirically Runtime and error rate plotted against concurrency for a fixed download of two hundred tiles. Runtime falls steeply from four to eight concurrent requests and then flattens, while the 429 rate stays at zero until twelve and climbs sharply after sixteen. The useful setting is the knee of the runtime curve, not the largest number the client will accept. 200 tiles · runtime (solid) and 429 rate (dashed) knee at 8 429s climb 4 8 12 16 32 concurrent requests Past the knee you are not going faster, you are just generating retries — and teaching the endpoint's operator to block you.

Troubleshooting Partial-Failure and Rate-Limit Issues

Failure Root Cause Fix
Flow aborts on the first bad tile .map() called without return_state=True Return states and partition completed from failed after wait()
Wall of HTTP 429 across the fan-out No concurrency limit; all runs admitted at once Register a tag concurrency limit sized to the API rate envelope
Retries make the 429 storm worse Flat or zero retry delay Use a retry_delay_seconds backoff list so retries space out
Some tiles silently missing from output Successes and failures never partitioned Inspect each state.is_completed(); dead-letter the rest
Limit ignored, endpoint still flooded Task not tagged, or tag name mismatched the limit Ensure the task tags exactly match the registered concurrency-limit tag
Re-run re-downloads everything No idempotency check before the request Short-circuit on dest.exists() before issuing the HTTP call

Integration Note

Bounded mapping is the workhorse of the Extract stage in orchestrating spatial pipelines with Prefect: the discovery task resolves the tile list, this recipe downloads it under a rate cap, and the reduce step consumes the completed/dead_letter split to build a manifest and alert on quarantine volume. It pairs directly with caching geospatial task results in Prefect — because cache hits return before the task body runs, a cached tile never consumes a concurrency slot, so a mostly-cached nightly run finishes in seconds while only the new tiles queue against the download limit.