Guide · AWS EventBridge Pipes

EventBridge Pipes Error Handling

EventBridge Pipes has two distinct DLQ layers that people consistently confuse, and a RUNNING_FAILED state that silently stops processing until you manually intervene. For MCP server event pipelines, getting error handling right determines whether a downstream failure causes permanent data loss (events silently dropped), endless retries (pipe stuck on a poison-pill batch), or clean dead-lettering (failed events routed to a DLQ for investigation and replay). The three most common mistakes: teams rely only on the SQS source queue's DLQ (maxReceiveCount) for failure isolation, not realizing that the SQS DLQ only catches messages that fail to be polled successfully — messages that are polled, consumed by Pipes, and then fail during enrichment or target invocation are handled by the pipe's own destinationConfig.onFailure DLQ, not the queue's DLQ. Teams don't configure maximumRetryAttempts for Kinesis/DynamoDB sources, which defaults to -1 (retry indefinitely) — a single poison-pill batch can block that shard's entire processing indefinitely. Teams put an EventBridge Bus as the Pipes target and create a rule that routes events back to the same source queue — this creates an infinite event loop that amplifies costs and can cause queue flooding.

TL;DR

Configure both the source queue's DLQ (for pre-pipe failures) and the pipe's destinationConfig.onFailure DLQ (for enrichment/target failures). Set maximumRetryAttempts to a finite value (3-10) for Kinesis/DynamoDB sources to prevent indefinite blocking. Use onPartialBatchItemFailure: AUTOMATIC_BISECT to isolate individual failing records. When RUNNING_FAILED: fix the root cause, then call aws pipes start-pipe.

Two DLQ layers — source DLQ vs pipe DLQ

Understanding which layer handles which failures is the foundation of Pipes error handling:

Failure point Which DLQ catches it? Configuration
SQS message polled but Pipes fails to start processing (infra issue) SQS source queue DLQ (maxReceiveCount) Queue.deadLetterQueue.maxReceiveCount
Filter evaluation error on source record SQS source queue DLQ Queue.deadLetterQueue.maxReceiveCount
Enrichment Lambda throws exception or times out Pipe DLQ (destinationConfig.onFailure) sourceParameters.*.destinationConfig.onFailure.destination
Target invocation fails (Lambda error, DynamoDB throttle, etc.) Pipe DLQ (destinationConfig.onFailure) sourceParameters.*.destinationConfig.onFailure.destination
Kinesis/DynamoDB record too old (maximumRecordAgeInSeconds exceeded) Pipe DLQ (destinationConfig.onFailure) sourceParameters.*.destinationConfig.onFailure.destination
// CDK: full error handling configuration for SQS-sourced pipe
import { Queue } from "aws-cdk-lib/aws-sqs";
import { aws_pipes as pipes } from "aws-cdk-lib";

// Layer 1: SQS source DLQ (catches messages that fail at the queue level)
const sourceDlq = new Queue(this, "SourceDlq", {
  queueName: "mcp-tool-calls-source-dlq",
  retentionPeriod: cdk.Duration.days(14),
});

const toolCallQueue = new Queue(this, "ToolCallQueue", {
  queueName: "mcp-tool-calls",
  visibilityTimeout: cdk.Duration.seconds(120),
  deadLetterQueue: {
    queue: sourceDlq,
    maxReceiveCount: 3,  // Move to DLQ after 3 failed receive cycles
  },
});

// Layer 2: Pipe DLQ (catches enrichment/target failures after Pipes consumed the message)
const pipeDlq = new Queue(this, "PipeDlq", {
  queueName: "mcp-tool-calls-pipe-dlq",
  retentionPeriod: cdk.Duration.days(14),
});

const pipe = new pipes.CfnPipe(this, "McpEventPipe", {
  // ...
  source: toolCallQueue.queueArn,
  sourceParameters: {
    sqsQueueParameters: {
      batchSize: 10,
      maximumBatchingWindowInSeconds: 5,
    },
    // Note: destinationConfig is NOT available for SQS sources in Pipes
    // For SQS, the SQS-level DLQ + pipe RUNNING_FAILED state handles failures
    // Pipe DLQ is configured via the KINESIS/DYNAMODB parameters instead
  },
  // ...
});

Important nuance: the pipe-level destinationConfig.onFailure is configured inside the sourceParameters for streaming sources (Kinesis, DynamoDB) but is not available for SQS sources in Pipes. For SQS-sourced pipes, failed batches are retried based on the SQS visibility timeout and maxReceiveCount — the message becomes visible again after visibility timeout expires and gets re-polled. If it exceeds maxReceiveCount, it goes to the SQS source DLQ. This is why the SQS visibility timeout must exceed total pipe processing time: a visibility timeout that's too short causes the message to become visible before Pipes finishes, leading to duplicate processing before the message eventually hits maxReceiveCount and is dead-lettered.

Kinesis/DynamoDB retry configuration

// CDK: Kinesis source with bounded retry configuration
const pipe = new pipes.CfnPipe(this, "KinesisPipe", {
  source: eventStream.streamArn,
  sourceParameters: {
    kinesisStreamParameters: {
      startingPosition: "LATEST",
      batchSize: 100,
      maximumBatchingWindowInSeconds: 5,

      // Retry config — CRITICAL: don't leave at -1 (indefinite)
      maximumRetryAttempts: 3,        // Retry failed batches up to 3 times
      maximumRecordAgeInSeconds: 3600, // Drop records older than 1h (don't retry stale events)

      // Partial failure: AUTOMATIC_BISECT splits a failing batch in half
      // to isolate the record(s) causing failures
      onPartialBatchItemFailure: "AUTOMATIC_BISECT",

      // Pipe-level DLQ: receives the failed records after retry exhaustion
      destinationConfig: {
        onFailure: {
          destination: pipeDlq.queueArn,  // SQS or SNS
        },
      },
    },
  },
  // ...
});

// Grant pipe role to write to the DLQ
pipeRole.addToPolicy(new PolicyStatement({
  actions: ["sqs:SendMessage"],
  resources: [pipeDlq.queueArn],
}));

AUTOMATIC_BISECT — partial batch failure isolation

When onPartialBatchItemFailure: "AUTOMATIC_BISECT" is set, and a batch fails (enrichment or target throws for the batch), Pipes splits the batch in half and retries each half independently. This isolates the failing record(s) to progressively smaller batches until the failing record is alone in a batch of 1, at which point it can be sent to the DLQ without affecting neighboring records.

This is available for Kinesis and DynamoDB Streams sources. It is not available for SQS sources in Pipes (for SQS, use Lambda ESM with reportBatchItemFailures / batchItemFailures response format instead).

// Target Lambda: how to handle partial batch failures
// For Kinesis/DynamoDB sources with AUTOMATIC_BISECT, the Lambda target
// does NOT need to use batchItemFailures response format (unlike Lambda ESM).
// Pipes handles bisection automatically when the Lambda throws.

// Pattern: throw on any failure — let Pipes bisect
exports.handler = async (events) => {
  const results = await Promise.allSettled(
    events.map(evt => writeToTarget(evt))
  );

  const failures = results.filter(r => r.status === "rejected");
  if (failures.length > 0) {
    // Throw to signal batch failure — Pipes bisects automatically
    throw new Error(`${failures.length}/${events.length} events failed`);
  }
  // No return value needed for Lambda targets in FIRE_AND_FORGET mode
};

// Note: if using invocationType: "REQUEST_RESPONSE", target Lambda return
// value is ignored by Pipes (enrichment Lambda return IS used — target is not)

RUNNING_FAILED — what it means and how to recover

RUNNING_FAILED is a terminal error state where the pipe stops polling the source entirely. It occurs when:

Recovery steps:

  1. Run aws pipes describe-pipe --name YOUR_PIPE_NAME and read the StateReason field — it names the specific failure (enrichment exception message, target error code).
  2. Fix the root cause (Lambda bug, DynamoDB capacity, enrichment timeout).
  3. If the pipe DLQ has the failed records, decide whether to replay or discard them.
  4. Call aws pipes start-pipe --name YOUR_PIPE_NAME to resume polling from where it stopped.
# CLI: diagnose RUNNING_FAILED and resume
aws pipes describe-pipe \
  --name mcp-tool-call-router \
  --query '{State: State, StateReason: StateReason, LastModifiedTime: LastModifiedTime}'

# After fix, resume:
aws pipes start-pipe --name mcp-tool-call-router

# Check pipe DLQ for failed records:
aws sqs get-queue-attributes \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/mcp-tool-calls-pipe-dlq \
  --attribute-names ApproximateNumberOfMessages

Self-referential pipe loops — the EventBridge Bus target trap

If your pipe target is an EventBridge Bus, and you have a Rule on that bus that routes events matching your pipe's output back to the same SQS queue that is the pipe's source, you have created an infinite loop:

SQS → Pipe → EventBridge Bus → Rule → SQS → Pipe → ...

Each cycle puts a message back on the queue. The loop amplifies with every round: if 10 messages are processed, each produces 10 more, resulting in 100 → 1,000 → 10,000 messages in minutes. This can overwhelm your SQS queue, hit Pipes throughput limits, and generate significant unexpected costs.

// Safe pattern: use dead-letter routing to a DIFFERENT queue
// or use an event detail-type prefix to stop the loop

// Option 1: add a "processed" flag to the target event and filter it out
const noLoopFilter = {
  body: {
    // Only process events that haven't been routed through this pipe before
    processedByPipe: [{ "exists": false }],
  },
};

// Option 2: use different event detail-type on the bus to prevent re-routing
// PutEvents: set detail-type = "McpToolCallProcessed"
// Rule: only match detail-type = "McpToolCallRaw" (not "McpToolCallProcessed")
// → loop is broken because processed events don't match the rule

// Option 3: target a different downstream queue (not the pipe's source)
// SQS-source → Pipe → EventBridge Bus → Rule → DIFFERENT_SQS_QUEUE
// No loop possible — different queue isn't the pipe's source

Monitor for loops using a CloudWatch alarm on the SQS queue's ApproximateNumberOfMessagesVisible metric — a sudden exponential spike is the signature of an event loop. Set an alarm at 10x the expected maximum queue depth.

Error handling decision tree

Failure scenario Recommended configuration
SQS source + occasional poison-pill messages SQS DLQ with maxReceiveCount=3; SQS visibility timeout > total pipe time
Kinesis source + occasional bad records AUTOMATIC_BISECT + maximumRetryAttempts: 3 + pipe DLQ
Enrichment Lambda intermittently failing Pipe DLQ + maximumRetryAttempts: 3; add exponential backoff in Lambda if calling external API
Target DynamoDB throttling under load Switch table to on-demand billing; add maximumRetryAttempts: 10 to absorb transient throttle
Pipe enters RUNNING_FAILED frequently Configure pipe DLQ so failed records are dead-lettered instead of blocking; set finite maximumRetryAttempts
SQS queue depth growing unexpectedly Check for event loop (target puts events back on source); add CloudWatch alarm on queue depth