Implementing Dead Letter Queues for Failed Vector Jobs
Set maxReceiveCount to 3–5 on your primary SQS queue or Pub/Sub subscription, bind a dedicated dead-letter destination, and route failed vector messages there with structured diagnostic metadata. This decouples your primary ingestion pipeline from a failure-routing layer, preventing toxic spatial payloads from poisoning downstream consumers. Across AWS, GCP, and Azure, this pattern replaces blind retry loops with deterministic failure isolation.
Why Vector Processing Needs Explicit Failure Routing
Geospatial vector workloads fail in predictable, repeating categories that generic retry logic cannot distinguish. Coordinate transformations, topology validation, and spatial joins routinely hit edge cases that surface as distinct exception types inside shapely, pyproj, or GEOS:
- Invalid geometry: Self-intersecting polygons, unclosed rings, or missing CRS definitions break
shape()andTransformercalls withTopologicalErrororGEOSExceptionbefore any meaningful work begins. - Resource exhaustion: Oversized multipart geometries or dense point clouds exceed Lambda’s 10 GB memory ceiling or Cloud Functions 2nd gen’s 32 GB limit, triggering OOM kills that produce no useful stack trace.
- Transient rate limits: Tile servers, elevation APIs, or basemap providers return 429 responses, causing
requests.exceptions.HTTPErroror timeout exceptions that are genuinely worth retrying. - Serialization failures: Malformed GeoJSON or WKT payloads fail schema validation with
json.JSONDecodeErroror PydanticValidationErrorbefore processing begins — these should never be retried.
Without explicit failure routing, these errors either silently drop or trigger infinite redelivery loops that exhaust compute quotas. Routing failed messages to a DLQ is the fundamental fault-tolerance primitive in SQS and Pub/Sub Queue Routing Strategies, where each message category gets a deterministic outcome rather than an unbounded retry.
Prerequisites
Before configuring DLQs for vector jobs, verify the following:
- Runtime: Python 3.11+ (Lambda) or Python 3.12 (Cloud Functions 2nd gen). Earlier runtimes have known
shapely2.x incompatibilities with GEOS 3.12. - Dependencies:
shapely>=2.0.0,pyproj>=3.6.0,boto3>=1.28.0(AWS) orgoogle-cloud-pubsub>=2.21.0(GCP). - IAM permissions (AWS):
sqs:SendMessage,sqs:ReceiveMessage,sqs:DeleteMessage,sqs:GetQueueAttributeson both the primary queue and the DLQ ARN. - IAM permissions (GCP):
pubsub.subscriptions.consumeon the source subscription,pubsub.topics.publishon the dead-letter topic, pluspubsub.subscriptions.createto bind the policy. - Environment variables: Set
PRIMARY_QUEUE_URL,DLQ_URL(AWS) orPROJECT_ID,DLQ_TOPIC_ID(GCP) as Lambda environment variables or Cloud Functions runtime configuration — never hardcode queue URLs.
Cross-Cloud DLQ Configuration
Each platform implements dead-letter routing differently. The architectural contract is identical: set a receive threshold, bind a secondary destination, and have your consumer explicitly acknowledge or reject messages.
AWS SQS — RedrivePolicy
Attach a RedrivePolicy to the source queue. Set maxReceiveCount to 3–5 for heavy geometry workloads. Configure the visibility timeout to exceed your longest expected spatial operation (coordinate reprojection of a dense multipolygon can take 8–12 seconds on a cold Lambda). For FIFO queues, preserve MessageDeduplicationId and MessageGroupId in the DLQ to prevent duplicate reprocessing.
aws sqs set-queue-attributes \
--queue-url "$PRIMARY_QUEUE_URL" \
--attributes '{
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:123456789012:vector-dlq\",\"maxReceiveCount\":\"4\"}",
"VisibilityTimeout": "120"
}'
GCP Pub/Sub — deadLetterPolicy
Pub/Sub requires a separate topic and subscription pair for the DLQ. The minimum maxDeliveryAttempts is 5; Pub/Sub will not accept lower values. The Pub/Sub service account ([email protected]) needs pubsub.publisher on the dead-letter topic and pubsub.subscriber on the source subscription.
gcloud pubsub subscriptions modify-push-config geo-vector-sub \
--dead-letter-topic=projects/$PROJECT_ID/topics/vector-dlq \
--max-delivery-attempts=5
Azure Service Bus — MaxDeliveryCount
Configure MaxDeliveryCount on the queue or subscription. Service Bus automatically moves exhausted messages to the $DeadLetterQueue sub-queue. Read from queue_name/$DeadLetterQueue explicitly. Azure also routes messages to the DLQ when DeadLetteringOnMessageExpiration is enabled and TTL expires.
| Platform | Config key | Minimum | Recommended for vector | DLQ destination type |
|---|---|---|---|---|
| AWS SQS | maxReceiveCount |
1 | 4 | Secondary SQS queue |
| GCP Pub/Sub | maxDeliveryAttempts |
5 | 5 | Secondary Pub/Sub topic |
| Azure Service Bus | MaxDeliveryCount |
1 | 4 | $DeadLetterQueue sub-queue |
Implementation
The consumer below handles all four failure categories, writes structured diagnostic metadata to the DLQ, then explicitly deletes the original message to prevent redelivery. It targets AWS Lambda but the exception-handling logic applies unchanged to GCP Cloud Functions 2nd gen (swap the SQS calls for Pub/Sub nack()/ack()).
import json
import logging
import os
import boto3
from botocore.exceptions import ClientError
from pyproj import Transformer
from pyproj.exceptions import CRSError
from shapely.errors import GEOSException, TopologicalError
from shapely.geometry import shape
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# Read from Lambda environment variables — never hardcode queue URLs
PRIMARY_QUEUE_URL = os.environ["PRIMARY_QUEUE_URL"]
DLQ_URL = os.environ["DLQ_URL"]
sqs = boto3.client("sqs")
def _send_to_dlq(body: dict, error_type: str, error_message: str,
geometry_type: str, trace_id: str) -> None:
"""Write a structured failure record to the DLQ."""
dlq_payload = {
"original_message": body,
"error_type": error_type,
"error_message": error_message,
"geometry_type": geometry_type,
"trace_id": trace_id,
}
sqs.send_message(QueueUrl=DLQ_URL, MessageBody=json.dumps(dlq_payload))
def handler(event, context):
for record in event.get("Records", []):
receipt_handle = record["receiptHandle"]
message_id = record["messageId"]
try:
body = json.loads(record["body"])
except json.JSONDecodeError as exc:
# Malformed envelope — no point retrying; route directly to DLQ
logger.error("JSON parse failure on %s: %s", message_id, exc)
_send_to_dlq(
body={"raw": record["body"]},
error_type="JSONDecodeError",
error_message=str(exc),
geometry_type="unknown",
trace_id=context.aws_request_id,
)
sqs.delete_message(QueueUrl=PRIMARY_QUEUE_URL, ReceiptHandle=receipt_handle)
continue
payload = body.get("geometry", {})
geometry_type = payload.get("type", "unknown")
try:
# 1. Parse and validate geometry
geom = shape(payload)
if not geom.is_valid:
raise TopologicalError(
f"Invalid geometry ({geom.geom_type}): {geom.is_valid_reason}"
)
# 2. Reproject from source CRS to Web Mercator
source_crs = body.get("crs", "EPSG:4326")
transformer = Transformer.from_crs(source_crs, "EPSG:3857", always_xy=True)
projected_coords = [
transformer.transform(x, y) for x, y in geom.exterior.coords
]
# 3. Downstream spatial work (topology checks, attribute joins, etc.)
logger.info(
"Processed %s geometry: %d vertices projected from %s",
geometry_type, len(projected_coords), source_crs,
)
# Explicit delete — SQS won't remove on Lambda success automatically
sqs.delete_message(QueueUrl=PRIMARY_QUEUE_URL, ReceiptHandle=receipt_handle)
except MemoryError as exc:
# OOM: serverless runtimes don't always surface these in logs
logger.error("OOM on %s (%s): %s", message_id, geometry_type, exc)
_send_to_dlq(body, "MemoryError", str(exc), geometry_type, context.aws_request_id)
sqs.delete_message(QueueUrl=PRIMARY_QUEUE_URL, ReceiptHandle=receipt_handle)
except (TopologicalError, GEOSException) as exc:
# Invalid geometry — may be auto-repairable via make_valid()
logger.warning("Geometry error on %s: %s", message_id, exc)
_send_to_dlq(body, type(exc).__name__, str(exc), geometry_type, context.aws_request_id)
sqs.delete_message(QueueUrl=PRIMARY_QUEUE_URL, ReceiptHandle=receipt_handle)
except CRSError as exc:
# Bad or unsupported CRS definition — not worth retrying
logger.error("CRS error on %s: %s", message_id, exc)
_send_to_dlq(body, "CRSError", str(exc), geometry_type, context.aws_request_id)
sqs.delete_message(QueueUrl=PRIMARY_QUEUE_URL, ReceiptHandle=receipt_handle)
except ClientError as exc:
# SQS API failure — let Lambda's retry mechanism handle this
logger.error("SQS ClientError on %s: %s", message_id, exc)
raise # Do NOT delete; allow visibility timeout to expire for retry
Key implementation decisions:
- DLQ write before delete: Send to the DLQ first, then delete the original. Reversing the order risks permanent message loss if the DLQ send fails after deletion.
- Raise on
ClientError: If the SQS API itself is unavailable, re-raising lets the message return to the queue via visibility timeout expiry. Swallowing this exception would silently drop messages. - Separate exception branches: Separating
TopologicalErrorfromMemoryErrorfromCRSErrorgives the downstream triage function an actionableerror_typefield — automated repair workflows can match on this without reparsing stack traces.
Pre-Flight Schema Validation
DLQs catch runtime failures, but upstream validation reduces DLQ volume significantly. A lightweight Pydantic model at the API Gateway or ingress Lambda rejects structurally invalid payloads before they enter the primary queue — keeping the DLQ reserved for genuinely unprocessable spatial data.
from pydantic import BaseModel, field_validator
from typing import Any
class GeoPayload(BaseModel):
source_uri: str
crs: str = "EPSG:4326"
processing_mode: str
geometry: dict[str, Any]
@field_validator("geometry")
@classmethod
def geometry_must_have_type_and_coords(cls, v):
if "type" not in v or "coordinates" not in v:
raise ValueError("geometry must contain 'type' and 'coordinates'")
return v
# At ingress — reject before enqueuing
try:
validated = GeoPayload(**incoming_payload)
except Exception as exc:
return {"statusCode": 400, "body": f"Schema validation failed: {exc}"}
Verification Step
After deploying the consumer, confirm that a synthetic bad geometry reaches the DLQ within one delivery cycle:
# Send a self-intersecting polygon (bowtie) to the primary queue
aws sqs send-message \
--queue-url "$PRIMARY_QUEUE_URL" \
--message-body '{
"source_uri": "s3://my-bucket/test.geojson",
"crs": "EPSG:4326",
"processing_mode": "vector",
"geometry": {
"type": "Polygon",
"coordinates": [[[0,0],[1,1],[0,1],[1,0],[0,0]]]
}
}'
# Wait for maxReceiveCount cycles, then check DLQ depth
aws sqs get-queue-attributes \
--queue-url "$DLQ_URL" \
--attribute-names ApproximateNumberOfMessages
Expected output after one Lambda invocation:
{
"Attributes": {
"ApproximateNumberOfMessages": "1"
}
}
A count of 0 means the message is still in the primary queue (Lambda not triggered or consumer raising before the DLQ write). A count above 1 for a single test message indicates duplicate processing — check that sqs.delete_message() is reached in all exception branches.
For GCP, inspect DLQ depth via Cloud Monitoring:
gcloud pubsub subscriptions describe vector-dlq-sub \
--format="value(messageRetentionDuration,expirationPolicy)"
Triage and Reprocessing Workflows
Once messages land in the DLQ, bind a triage consumer that routes based on error_type:
- Auto-repair: For
TopologicalError, attemptshapely.make_valid()on the geometry, re-validate, and if the result passesis_valid, re-enqueue to the primary queue. Tag the re-sent message with"repaired": trueso downstream consumers can log the repair event. - Quarantine and alert: For
MemoryErroror oversized multipart geometries, publish a structured alert to PagerDuty or Slack withgeometry_type,source_uri, and approximate vertex count. Do not retry automatically. - Warehouse export for systemic analysis: For
CRSErrororJSONDecodeError, stream DLQ records to BigQuery, Redshift, or Azure Synapse. Aggregate bysource_uriprefix to identify providers consistently shipping malformed payloads — this data feeds the upstream schema validation rules described above.
This triage model aligns with the event-driven patterns where failure routing is a first-class data quality signal rather than a reactive afterthought. It also pairs naturally with batch vs stream geospatial processing decisions — auto-repaired geometries can re-enter the stream path, while systemic failures accumulate for offline batch repair against the source dataset.
Gotchas and Edge Cases
- Pub/Sub minimum
maxDeliveryAttemptsis 5, not 1. Attempting to set it to 3 via the API returns aINVALID_ARGUMENTerror. Plan your retry budget assuming at least 5 deliveries before a message routes to the dead-letter topic. - SQS FIFO DLQs must also be FIFO. If your primary queue is FIFO, the
deadLetterTargetArnmust point to a FIFO DLQ — SQS will reject a standard queue as the target. PreserveMessageGroupIdin your DLQ write to maintain ordering metadata for forensic analysis. - Lambda’s built-in retry does not count toward
maxReceiveCount. Lambda retries the sameReceiptHandleon function error. Each attempt increments theApproximateReceiveCountattribute. If your Lambda raises an unhandled exception (rather than writing to the DLQ and deleting), the message stays in the primary queue and the counter ticks towardmaxReceiveCount— which is fine for transientClientError, but means invalid geometries will burn retries before reaching the DLQ. [Ephemeral Storage Limits in AWS Lambda](/serverless-geospatial-architecture-platform-limits/ephemeral-storage-limits-in-aws-lambda/)can mask OOM as timeout. When shapely processes large multipolygons that spill intermediate data, the function may timeout (at 15 minutes maximum) rather than raisingMemoryError. AddApproximateNumberOfMessagesNotVisiblemonitoring to distinguish timeout-induced redelivery from genuine geometry failures.
Frequently Asked Questions
What maxReceiveCount should I set for vector geometry jobs?
Set maxReceiveCount to 3–5. Values above 5 waste compute retrying fundamentally broken geometries; values below 3 risk discarding transient failures caused by rate-limited tile servers or brief network partitions.
Why does GCP Pub/Sub require a separate topic for the DLQ?
Pub/Sub routes exhausted messages to a dead-letter topic (not a subscription), so you need a dedicated topic plus a subscription on that topic to consume failures. Unlike SQS, Pub/Sub does not retry from the dead-letter topic automatically — your consumer must drive reprocessing.
Should I delete the original message before or after writing to the DLQ?
Write to the DLQ first, then delete. If you delete first and the DLQ send fails, the message is permanently lost with no recovery path.
Related
- SQS and Pub/Sub Queue Routing Strategies — content-based routing, priority tiers, and spatial sharding for geospatial queues
- Batch vs Stream Geospatial Processing — when to route repaired DLQ messages back to the stream path vs. a batch repair job
- S3 and GCS Event Triggers for Shapefiles — upstream event source that feeds the primary queue where DLQ routing begins
- Ephemeral Storage Limits in AWS Lambda — timeout vs OOM distinction that affects how vector failures reach the DLQ
- Stripping Unnecessary Python Packages from AWS Lambda Layers — keep the consumer Lambda lean to reduce OOM-induced DLQ pressure