Skip to content

Handling Throttling Errors in Step Functions Map State

Give Lambda.TooManyRequestsException its own Retry block with IntervalSeconds: 2, MaxAttempts: 6, BackoffRate: 2.0 and JitterStrategy: FULL, then set MaxConcurrency to about 95% of the worker’s reserved concurrency so the retries have somewhere to land. Add ToleratedFailurePercentage: 2 so a handful of poison tiles cannot discard a scene that is 99.9% complete. A Distributed Map will run up to 10,000 parallel child executions if you let it — ten times the default Lambda concurrency in the same region — so the cap is not a tuning preference, it is the thing that stops the orchestrator from manufacturing its own throttles.

What the Map State Is Actually Doing

A Distributed Map reads an item source, starts a child execution per item (or per batch), and tracks their outcomes. Each child invokes the tile worker. When the worker’s concurrency is exhausted, the invocation is refused before an execution environment exists — the child records a LambdaFunctionScheduleFailed event carrying Lambda.TooManyRequestsException, and unless a Retry block matches that error name, the child fails immediately.

This produces a distinctive and easily misread failure. The tile worker’s own log group is empty for the affected tiles, because no handler ever ran. The Errors metric for the function is zero. What is non-zero is Throttles, and what is visible in the Step Functions console is a Map state with, say, 4,100 succeeded and 2,192 failed children, all failing within the first thirty seconds. It looks like a code fault affecting a third of the data. It is a capacity fault affecting whichever tiles happened to arrive after the ceiling was reached — the concurrency and throttling for tile fan-out overview covers why those tiles are indistinguishable from each other.

State transitions of a throttled child execution in a Distributed MapA state machine with five states: Scheduled, where the child asks Lambda for an invocation; Throttled, where the request is refused with 429 Rate Exceeded and no execution environment is created; Backing off, where the retry waits between two and sixty seconds with full jitter; Running, once a slot frees up and an environment is created; and Recorded, where the outcome is written to the results manifest or the dead-letter queue. A return path leads from Backing off to Throttled when the reservation is still saturated.The life of one throttled tileScheduledchild asks LambdaThrottled429 Rate ExceededBacking off2–60 s, full jitterRunningenvironment createdRecordedmanifest or DLQquota fullretry armedslot freetile writtenstill saturatedSix attempts is the budget. A tile that exhausts them lands in Recorded as a FAILED entry, and ToleratedFailurePercentage decides whetherthat fails the whole scene.
A tile that is never throttled goes straight from Scheduled to Running. The two middle states exist only while the reservation is saturated — and produce no entry in the worker's own log group.

The retry loop is the recovery path, but only for errors that are genuinely transient. Retrying a throttle is correct: the capacity will exist a few seconds later. Retrying a CPLE_OpenFailed because PROJ_LIB is unset is not: it will fail identically six times and consume six invocations’ worth of concurrency doing so, making the throttling worse. Separating the two error classes into separate Retry blocks is the single most useful change to make to a Map state that is misbehaving.

Prerequisites

  • Reserved concurrency already set on the tile worker. The retry configuration below assumes a known ceiling; without one the backoff is chasing a number that other functions move. See reserved concurrency for geospatial Lambda fan-out.
  • A Distributed Map, not an inline Map. Inline Map is capped at 40 concurrent iterations and keeps all state in the execution’s 256 KB payload, which a tile manifest exceeds quickly. ProcessorConfig.Mode must be DISTRIBUTED.
  • An S3 location for the results manifests. ResultWriter needs a bucket and prefix, and the state machine role needs s3:PutObject there plus s3:GetObject on the item source.
  • State machine role permissions: states:StartExecution, states:DescribeExecution and states:StopExecution on itself — a Distributed Map starts its own child executions — plus lambda:InvokeFunction on the worker.
  • Idempotent workers. Retries mean a tile may be processed more than once. Derive the output key deterministically from the input URI and tile index so a repeat write is harmless, in the same way deduplicating S3 event notifications for idempotent ingestion does for the trigger side.

Implementation

One Map state, two retry classes, a bounded concurrency, a failure tolerance, and a results manifest. This is the whole configuration; everything else is the worker’s business.

json
{
  "Comment": "Tile a scene with a bounded Distributed Map",
  "StartAt": "TileFanout",
  "States": {
    "TileFanout": {
      "Type": "Map",
      "MaxConcurrency": 380,
      "ToleratedFailurePercentage": 2,
      "Label": "TileFanout",
      "ItemReader": {
        "Resource": "arn:aws:states:::s3:getObject",
        "ReaderConfig": { "InputType": "JSON" },
        "Parameters": {
          "Bucket.$": "$.manifest_bucket",
          "Key.$": "$.manifest_key"
        }
      },
      "ItemBatcher": {
        "MaxItemsPerBatch": 8,
        "BatchInput": {
          "source_uri.$": "$.source_uri",
          "dst_crs.$": "$.dst_crs"
        }
      },
      "ItemProcessor": {
        "ProcessorConfig": {
          "Mode": "DISTRIBUTED",
          "ExecutionType": "STANDARD"
        },
        "StartAt": "ProcessTileBatch",
        "States": {
          "ProcessTileBatch": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
              "FunctionName": "tile-worker",
              "Payload.$": "$"
            },
            "Retry": [
              {
                "Comment": "Capacity errors. Patient, jittered, many attempts.",
                "ErrorEquals": [
                  "Lambda.TooManyRequestsException",
                  "Lambda.ServiceException",
                  "Lambda.SdkClientException",
                  "States.TaskFailed"
                ],
                "IntervalSeconds": 2,
                "MaxAttempts": 6,
                "BackoffRate": 2.0,
                "MaxDelaySeconds": 60,
                "JitterStrategy": "FULL"
              },
              {
                "Comment": "Everything else. One retry, then give up and record it.",
                "ErrorEquals": ["States.ALL"],
                "IntervalSeconds": 1,
                "MaxAttempts": 1,
                "BackoffRate": 1.0
              }
            ],
            "Catch": [
              {
                "ErrorEquals": ["States.ALL"],
                "ResultPath": "$.error",
                "Next": "RecordFailedTile"
              }
            ],
            "End": true
          },
          "RecordFailedTile": {
            "Type": "Task",
            "Resource": "arn:aws:states:::sqs:sendMessage",
            "Parameters": {
              "QueueUrl": "https://sqs.eu-west-1.amazonaws.com/123456789012/tile-dlq",
              "MessageBody.$": "$"
            },
            "End": true
          }
        }
      },
      "ResultWriter": {
        "Resource": "arn:aws:states:::s3:putObject",
        "Parameters": {
          "Bucket": "geo-pipeline-artifacts",
          "Prefix": "map-results"
        }
      },
      "End": true
    }
  }
}

Four decisions in that definition are worth naming.

JitterStrategy: FULL rather than the default. Without jitter, every throttled tile waits exactly 2, 4, 8, 16, 32 and 64 seconds, which means every throttled tile retries at the same instant. The retry wave is as wide as the original wave and throttles identically — a synchronised retry storm. Full jitter randomises each wait uniformly between zero and the computed interval, spreading the retries across the window and letting capacity be reclaimed incrementally.

MaxDelaySeconds: 60. Six attempts at BackoffRate: 2.0 from a 2-second base would otherwise reach 64 seconds on the last attempt, and a wide fan-out compounds that into a long tail where the state machine is mostly waiting. Capping the delay keeps the worst-case retry latency bounded at roughly 2+4+8+16+32+60 seconds.

ItemBatcher with MaxItemsPerBatch: 8. This is the lever for the 10,000 child-execution ceiling. A 24,037-tile fan-out cannot run as 24,037 simultaneous children, but batching eight tiles per invocation reduces the child count to 3,005 — and reduces the invocation count by the same factor, which cuts cold starts proportionally. Batch size should be set so the batch still fits inside the worker’s timeout: eight 512-px tiles at roughly 900 ms each is 7.2 seconds against a 5-minute limit, comfortable even with a cold start.

A Catch that writes to a queue instead of failing. Combined with ToleratedFailurePercentage, this converts an unrecoverable tile into a record rather than an outage. The dead-letter queue pattern is the same one described in implementing dead-letter queues for failed vector jobs.

Retry wait windows for a throttled tile under full jitterA bar chart of the six retry wait windows produced by IntervalSeconds 2 with BackoffRate 2.0 and MaxDelaySeconds 60: zero to two seconds, zero to four, zero to eight, zero to sixteen, zero to thirty-two, and zero to sixty seconds on the sixth and final attempt.Six attempts, jittered, capped at 60 secondsRetry 10–2 sRetry 20–4 sRetry 30–8 sRetry 40–16 sRetry 50–32 sRetry 60–60 s060 s — MaxDelaySeconds capWorst case a tile waits about 122 seconds across all six attempts before it is recorded as failed; the uncapped schedule would reach 126 andkeep climbing on any further attempt.
Each bar is the window a retry is drawn from, not a fixed wait. Without full jitter every throttled tile would retry at exactly the same instant and re-create the wave that throttled it.

Failure Tolerance and the Child-Execution Ceiling

ToleratedFailurePercentage defaults to zero, which means a single failed child fails the Map state and, with it, the scene. For a 6,292-tile fan-out that is a poor trade: one corrupt band or one tile that straddles a nodata boundary discards 6,291 successful outputs. Setting the tolerance to 2 lets the Map complete and record the failures, so a repair pass can process 126 tiles rather than the whole scene.

Failure tolerance across a 6,292-tile sceneA grid of one hundred cells, each representing one per cent of a 6,292-tile Sentinel-2 fan-out. One cell is marked as failed after exhausting its retries, one further cell is marked as unspent tolerance, and the remaining ninety-eight succeeded.What a 2% tolerance actually buys1 cell = 1% of the scene6,292 tiles, 100 cellssucceededfailed after 6 retriestolerance unspentToleratedFailurePercentage 2 allows 126 tiles to fail. Use ToleratedFailureCount instead when the fan-out width varies between scenes,because a percentage of a small manifest rounds down to zero.
At the default tolerance of zero, that single failed cell fails the Map state and discards every finished output beside it. A tolerance of 2 turns a lost scene into a 63-tile repair pass.

Choosing the number is a judgement about what a partial scene is worth. For a mosaic that will be published as a single COG, any missing tile is a hole in the product and the tolerance should be near zero — you want the Map to fail loudly and the merge step never to run, which is the assumption merging tiled Lambda outputs into a COG is built on. For an independently addressable tile pyramid, where each tile is served on its own, a missing tile is a gap a repair pass can fill later and a tolerance of 1–2% is the difference between a five-minute repair and a forty-minute re-run.

Use ToleratedFailureCount instead when the fan-out width varies a lot between scenes — a percentage of a small manifest can round down to zero tolerance, which is rarely what was intended. The two are mutually exclusive.

The 10,000 parallel child-execution ceiling is a separate constraint and is often misread as a limit on total items. It is not: a Distributed Map can iterate millions of items from an S3 inventory or a CSV manifest. What it cannot do is have more than 10,000 child executions in flight simultaneously. In practice MaxConcurrency should sit far below that anyway — bounded by the Lambda quota, not by the Map’s own ceiling — so the 10,000 figure only becomes binding if you have raised the account concurrency quota into the same range.

Verification

After a run, the results manifest is the authoritative record of what happened. Parse it rather than reading the console.

python
import boto3
import json
from collections import Counter

s3 = boto3.client("s3")
sfn = boto3.client("stepfunctions")

BUCKET = "geo-pipeline-artifacts"


def audit_map_run(execution_arn: str) -> dict:
    desc = sfn.describe_execution(executionArn=execution_arn)
    output = json.loads(desc.get("output") or "{}")
    prefix = output.get("ResultWriterDetails", {}).get("Key", "")

    manifest = json.loads(
        s3.get_object(Bucket=BUCKET, Key=prefix)["Body"].read()
    )

    reasons = Counter()
    failed_tiles = []
    for entry in manifest.get("ResultFiles", {}).get("FAILED", []):
        body = s3.get_object(Bucket=BUCKET, Key=entry["Key"])["Body"].read()
        for line in body.decode().splitlines()[1:]:      # skip CSV header
            _, _, _, cause = line.split(",", 3)
            reasons[cause.strip('"').split(":")[0]] += 1
            failed_tiles.append(line)

    succeeded = len(manifest.get("ResultFiles", {}).get("SUCCEEDED", []))
    report = {
        "status": desc["status"],
        "succeeded_result_files": succeeded,
        "failed_children": len(failed_tiles),
        "failure_reasons": dict(reasons),
    }

    throttle_share = reasons.get("Lambda.TooManyRequestsException", 0)
    assert throttle_share == 0, (
        f"{throttle_share} children exhausted their retries on throttles — "
        "MaxConcurrency is above what the worker's reservation can serve."
    )
    return report

Expected output from a correctly bounded run:

code
{'status': 'SUCCEEDED',
 'succeeded_result_files': 7,
 'failed_children': 4,
 'failure_reasons': {'CPLE_AppDefined': 4}}

Four failures with a GDAL cause and none with a throttle cause is the target state: the capacity problem is solved, and what remains is genuine data trouble in four tiles. The inverse — zero GDAL failures and a hundred throttle failures — means the retry block is working but the ceiling is still wrong, and no amount of retry tuning will fix it.

The manifest also carries a PENDING section, which is the one worth checking when a run looks complete but the outputs are short. Items land there when the Map stopped before dispatching them — because the failure tolerance was breached mid-run, or because someone stopped the execution. A non-empty PENDING list is the input to a resumed run: feed it back as the item source rather than re-reading the original manifest, and the repair pass processes only what was never attempted.

Cross-check against AWS/States metrics for the state machine: ExecutionThrottled should be zero, and ExecutionsFailed should be zero when the tolerance absorbed the residue. A non-zero ExecutionThrottled points at state-transition throttling rather than Lambda throttling, which is a different fix — reduce the number of states inside the ItemProcessor, or switch the inner workflow to EXPRESS.

Gotchas

  • States.TaskFailed in a capacity retry block is a blunt instrument. It matches any task failure, including a worker that raised a Python exception, so putting it alongside Lambda.TooManyRequestsException means real bugs get six patient retries too. It is included above because a Lambda-invoke task wraps some transient service errors that way, but if your worker raises typed errors, list those explicitly in the second block and remove States.TaskFailed from the first.

  • ToleratedFailurePercentage does not stop the failures from being retried. Tolerance is evaluated after retries are exhausted. A 2% tolerance on a fan-out where 40% of tiles are throttling still burns every retry attempt on every one of those tiles before the Map decides it has failed. Tolerance is a completion policy, not a circuit breaker; the circuit breaker is MaxConcurrency.

  • The results manifest is written even when the Map fails, but not when the execution is stopped. Manually stopping a runaway Map execution loses the record of which tiles completed, forcing a full re-run. Prefer setting the worker’s reserved concurrency to zero to stall the fan-out — the Map keeps its state, retries stall harmlessly, and restoring the reservation resumes it.

  • ItemBatcher changes the shape of the worker’s event. With batching enabled the payload is {"BatchInput": {...}, "Items": [...]} rather than a bare item. A worker written for unbatched Map will receive an event it does not recognise and fail every child at once, which looks alarmingly like a total outage on the first deployment after enabling batching.

Frequently Asked Questions

Which error name does Step Functions use for a Lambda throttle?

Lambda.TooManyRequestsException. It appears in the execution history as a LambdaFunctionScheduleFailed event whose cause contains Rate Exceeded. It is distinct from States.TaskFailed, which wraps an error your handler actually raised, and from States.ThrottledEvent, which is Step Functions throttling its own state transitions.

What does ToleratedFailurePercentage do?

It sets the share of child executions that may fail before the Map state itself is marked failed. At the default of zero, one bad tile out of 6,292 fails the entire scene. A tolerance of 2 lets the Map complete, records the failures in the results manifest, and leaves the successful outputs in place for a targeted repair pass.

How many child executions can a Distributed Map run in parallel?

Up to 10,000. This is a parallelism ceiling, not a limit on total items — a Distributed Map can iterate millions of items. Since the default Lambda concurrency in the same region is 1,000, MaxConcurrency should normally sit an order of magnitude below the Map’s own ceiling.

Should I retry throttles in the worker instead of in the Map state?

No. A throttled invocation never reaches your code, so there is nothing there to retry from. In-worker retries are the right place for downstream throttles — a DynamoDB ProvisionedThroughputExceededException or an external tile API 429 — because those happen inside a running invocation. Retrying an invocation throttle is necessarily the orchestrator’s job.


Back to Concurrency and Throttling for Tile Fan-Out