AWS SNS · 2026-10-29 · SNS Advanced arc
SNS Advanced Patterns for MCP Server Monitoring Pipelines: FIFO Topics, Filter Policies, Fan-Out, and Delivery Reliability
SNS is frequently used as nothing more than a notification bus — publish a message, a Lambda gets it, done — but four advanced features together transform it into a complete event-routing layer for MCP monitoring pipelines, each with a distinct non-obvious constraint that breaks deployments at the worst possible time. The SNS fan-out pattern routes a single probe-result publish to multiple independent SQS queues — alerting, analytics, audit, and S3 archiving all receive the same event in parallel without any application-layer router code — but every destination queue requires an explicit sqs:SendMessage allow in its resource policy scoped to the topic ARN with ArnEquals: aws:SourceArn; a policy that grants sqs:SendMessage to sns.amazonaws.com without a source condition is a confused deputy vulnerability that allows any SNS topic in any account to write to your queue, and SNS will silently discard messages rather than returning an error when the policy is wrong. SNS subscription filter policies let each consumer declare which messages it wants, evaluated by SNS at delivery time before the message reaches the subscriber — a latency-tier routing topology with three queues can be built entirely in filter policy JSON with no Lambda router, no DynamoDB lookup, and no code to maintain: string operators route by status value, numeric operators route by latency threshold, AND semantics combine conditions within a policy object, and OR semantics are implicit in value arrays — but the two filter policy scopes (MessageAttributes vs MessageBody) behave differently, filter policy changes take up to 15 minutes to propagate to all SNS delivery endpoints, and the 5 KB per-subscription limit combined with a 5-attribute AND maximum forces high-cardinality routing to move to a Lambda subscriber. FIFO topics add strict ordering and exactly-once delivery semantics for the specific case where downstream consumers need to process MCP endpoint state transitions in sequence — a healthy → degraded → unhealthy state machine that receives events out of order will produce incorrect alert states, and standard SNS at-least-once delivery with best-effort ordering cannot prevent this; FIFO topics enforce per-MessageGroupId delivery order and deduplicate within a 5-minute window, but subscriber types are restricted to SQS FIFO queues and Lambda only (no HTTP/S, email, or SMS), throughput is capped at 300 msg/s per message group (3,000 with PublishBatch), and using a single global MessageGroupId serializes the entire pipeline through a 300 msg/s bottleneck — per-endpoint groups eliminate that bottleneck and contain blast radius so a blocked group for one flaky endpoint does not delay status updates for thousands of others. Per-subscription DLQs and custom delivery retry policies close the visibility gap that makes SNS failures silent by default — the SNS Publish API always returns success regardless of downstream delivery outcomes, which means a misconfigured SQS queue resource policy, a Lambda under concurrency throttle, or a FIFO queue DLQ that has blocked a message group will all produce zero errors at the publisher while silently dropping probe results; enabling delivery status logging to CloudWatch Logs via SqsSuccessFeedbackRoleArn and SqsFailureFeedbackRoleArn on the topic makes per-delivery outcomes visible, and configuring a custom delivery policy with numMaxDelayRetries: 0 and 8 total retries causes exhaustion in ~3 minutes rather than the default 23 days, routing stale probe events to the subscription DLQ quickly enough for the DLQ alarm to be actionable. This guide synthesises all four patterns. Pattern 1 covers the fan-out topology — queue resource policy requirements, raw message delivery vs SNS envelope, cross-account configuration, and cross-region constraints — synthesised from SNS Fan-Out to SQS for MCP Servers — Parallel Processing, Raw Message Delivery, Cross-Account. Pattern 2 covers subscription filter policies — filter scope, string and numeric operators, AND/OR semantics, 5 KB limit, and CDK patterns — synthesised from SNS Subscription Filter Policies for MCP Servers — Attribute Routing, String Match, Numeric Operators. Pattern 3 covers FIFO topics — FIFO vs standard comparison, MessageGroupId granularity, deduplication modes, CDK, and throughput limits — synthesised from SNS FIFO Topics for MCP Servers — Ordered Event Delivery, MessageGroupId, Deduplication. Pattern 4 covers subscription DLQ and delivery retry — SNS DLQ vs SQS DLQ distinction, retry phases, fast-fail delivery policy, delivery status logging, and DLQ message structure — synthesised from SNS Subscription DLQ and Delivery Retry for MCP Servers — Per-Subscription Dead-Letter Queues, Exponential Backoff.
TL;DR
- Fan-out topology — single publish, multiple independent SQS queues: Create one SNS topic and subscribe multiple SQS queues. Each queue's resource policy requires
sqs:SendMessagefor thesns.amazonaws.comprincipal, conditioned withArnEquals: aws:SourceArn: <topic-arn>— omitting the source condition is a confused deputy vulnerability. EnablerawMessageDelivery: trueper subscription when consumers should receive the original JSON body directly, avoiding the SNS envelope double-parse (JSON.parse(envelope.Message)). Fan-out is synchronous at the SNS layer — all subscriptions are attempted in parallel before the Publish call returns; individual subscription delivery failures are retried independently without failing the publish. Cross-account fan-out requires the destination queue's resource policy to allow SNS from the source account; FIFO topics cannot deliver cross-region — use EventBridge cross-region replication for multi-region fan-out. - Subscription filter policies — attribute-based routing with no router code: Attach a
FilterPolicyJSON to each subscription. Default scope isMessageAttributes(evaluated against the per-message attributes map, no body parse). String operators: exact match (["unhealthy"]), OR via array (["degraded","unhealthy"]), prefix ([{"prefix":"eu-"}]), suffix ([{"suffix":"-prod"}]), anything-but ([{"anything-but":["healthy"]}]), exists. Numeric operators requireDataType:"Number"on the attribute:[{"numeric":[">",5000]}], range[{"numeric":[">",1000,"<",5000]}]. AND semantics: multiple top-level keys in the filter object. OR semantics: multiple values in a key's array. Limit: 5 KB per subscription, max 5 keys, max 150 values per key. Filter policy changes propagate in up to 15 minutes — do not test immediately afterSetSubscriptionAttributes. Messages that fail all subscription filters are billed at topic level but produce no delivery. - FIFO topics — per-endpoint ordered delivery, deduplication, 300 msg/s per group: Topic name must end in
.fifo; setfifo: truein CDK. Every publish requires aMessageGroupId— useendpoint-{endpointId}granularity to scope ordering per endpoint and prevent one blocked group from delaying other endpoints. Deduplication: either explicitMessageDeduplicationIdkeyed on{endpointId}-{probeRunId}, orcontentBasedDeduplication: trueon the topic (SHA-256 hash of body within 5-minute window). Only SQS FIFO queues and Lambda can subscribe to FIFO topics — no HTTP/S, email, or SMS. Throughput: 300 published msg/s per message group (3,000 withPublishBatchCommand). FIFO topics cannot deliver cross-region. A blocked message group (consumer stuck on a DLQ item) silently delays all subsequent messages in that group — alarm on FIFO DLQ depth> 0and on main queueApproximateNumberOfMessagesVisibledivergence. - Per-subscription DLQ + custom retry policy — visible failures, fast exhaustion: Set
deadLetterQueueon theSqsSubscription(CDK v2.x preferred) orcfnSubscription.redrivePolicy = { deadLetterTargetArn }(escape hatch). The DLQ resource policy must grantsqs:SendMessagetosns.amazonaws.comwithArnLike: aws:SourceArn: arn:aws:sns:region:account:*(subscription ARN not yet known at stack creation time). Custom delivery policy for MCP monitoring (fast-fail):numRetries:8, numNoDelayRetries:3, numMinDelayRetries:2, numMaxDelayRetries:0, minDelayTarget:5, maxDelayTarget:60, backoffFunction:"exponential"— exhausts in ~3 minutes, never the default 23-day post-backoff phase. Enable delivery status logging viacfnTopic.sqsSuccessFeedbackRoleArnandsqsFailureFeedbackRoleArn— the only way to see why a delivery failed.deadLetterReasonin DLQ message attributes:NO_ENDPOINT_REACHABLE(queue missing/wrong ARN),ACCESS_DENIED(resource policy missing),KMS_ACCESS_DENIED(CMK not granting SNS encrypt permission). Treat non-empty SNS subscription DLQ as tier-1 alert — it means the monitoring pipeline itself has a blind spot.
Pattern 1 — SNS Fan-Out: Single Publish, Parallel Independent Consumers
Why fan-out, and why it is not the same as multiple SQS queues
A naive solution to "I need multiple consumers for probe results" is to write to multiple SQS queues from the probe worker. This works, but it has two structural problems. First, the probe worker is now tightly coupled to the consumer topology — adding a fourth consumer requires modifying and redeploying the probe worker. Second, a slow or failing consumer cannot be isolated: if the probe worker writes to queues A, B, and C sequentially, a timeout on queue B delays the write to C.
The SNS fan-out pattern solves both problems. The probe worker publishes one message to one SNS topic. SNS delivers it to all subscribed queues in parallel, before the Publish call returns to the caller. Each queue gets an independent copy with its own visibility timeout, DLQ, consumer Lambda, and failure domain. Adding a fifth consumer requires creating a new SQS queue and subscription — no changes to the probe worker.
┌──────────────────────────┐
│ MCP Probe Worker │
│ (Lambda / ECS task) │
└──────────┬───────────────-─┘
│ SNS Publish (one call)
▼
┌──────────────────────────┐
│ SNS Topic │
│ mcp-probe-results │
└─────────┬────────────────-─┘
┌──────────┬─────────┼─────────┬──────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
SQS: SQS: SQS: SQS: SQS:
alerting analytics audit s3-archiver dashboard
(filter: (all) (all) (all) (filter:
unhealthy) latencyMs
>2000)
The queue resource policy requirement — and the security trap
SNS will silently discard messages and enter its retry loop if it cannot write to an SQS queue. No error is returned to the publisher — from the probe worker's perspective, every Publish call succeeded. The most common cause is a missing or overly broad SQS queue resource policy.
Every subscribed queue must have an access policy statement granting sqs:SendMessage to the sns.amazonaws.com service principal, conditioned on the source topic ARN:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "sns.amazonaws.com" },
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:123456789012:mcp-alerting-queue",
"Condition": {
"ArnEquals": {
"aws:SourceArn": "arn:aws:sns:us-east-1:123456789012:mcp-probe-results"
}
}
}]
}
Omitting the ArnEquals condition — granting sqs:SendMessage to all sns.amazonaws.com principals unconditionally — is a confused deputy vulnerability. Any SNS topic in any AWS account can write to your queue. CDK's SqsSubscription construct adds the correct conditioned policy automatically when you subscribe via topic.addSubscription(); manual subscription via the CLI or direct CloudFormation does not add it — you must write the resource policy explicitly.
Raw message delivery vs the SNS envelope
By default, SNS wraps the original message body in a JSON envelope before writing to the SQS queue. The SQS consumer receives an outer JSON object with fields Type, MessageId, TopicArn, Timestamp, SignatureVersion, and — critically — Message, which is the original body serialized as a JSON string. The consumer must parse twice: JSON.parse(sqsMsg.Body).Message gives the envelope, then JSON.parse(envelope.Message) gives the original payload.
Enable RawMessageDelivery: true per subscription when the consumer is purpose-built for this topic and does not need SNS metadata. The SQS message body is then the exact bytes the publisher sent — one JSON parse instead of two:
// CDK: three-queue fan-out with different raw delivery settings
const probeResultsTopic = new sns.Topic(this, "ProbeResultsTopic");
// Alerting queue: raw delivery + filter for non-healthy events
probeResultsTopic.addSubscription(
new subscriptions.SqsSubscription(alertingQueue, {
rawMessageDelivery: true,
filterPolicy: {
status: sns.SubscriptionFilter.stringFilter({
allowlist: ["degraded", "unhealthy"],
}),
},
})
);
// Analytics queue: raw delivery, all events
probeResultsTopic.addSubscription(
new subscriptions.SqsSubscription(analyticsQueue, {
rawMessageDelivery: true,
// no filter — receives every probe result
})
);
// Archiver queue: envelope delivery — archiver uses MessageId for idempotency
probeResultsTopic.addSubscription(
new subscriptions.SqsSubscription(archiverQueue, {
rawMessageDelivery: false,
})
);
Cross-account fan-out
SNS can deliver to an SQS queue in a different AWS account. The destination queue's resource policy must explicitly grant sqs:SendMessage to the source account's SNS topic ARN. CDK does not support cross-account topic subscriptions natively — create the subscription via CLI from the account that owns the topic:
aws sns subscribe \
--topic-arn arn:aws:sns:us-east-1:123456789012:mcp-probe-results \
--protocol sqs \
--notification-endpoint arn:aws:sqs:us-east-1:999999999999:cross-account-analytics-queue
If the destination queue uses an AWS-managed key (aws/sqs), cross-account SNS delivery fails with KMSAccessDeniedException. AWS-managed keys cannot have key policies modified to grant cross-account access. Switch to a customer-managed KMS key (CMK) and add a key policy statement granting kms:GenerateDataKey and kms:Decrypt to the sns.amazonaws.com principal from the source account.
Pattern 2 — Subscription Filter Policies: Attribute-Based Routing Without a Router
Why filter policies instead of consumer-side filtering
Without filter policies, every subscriber receives every message published to the topic. An alerting Lambda that only cares about status=unhealthy still receives — and is billed for — every healthy probe result. At 60-second probe intervals across 1,000 MCP endpoints, that is 1,440,000 Lambda invocations per day for a consumer that acts on roughly 0.1% of them.
SNS filter policies move the filtering decision to the SNS delivery layer. Messages that do not match a subscription's filter policy are never delivered to that subscriber — they are evaluated at the topic side before writing to the queue or invoking the Lambda. Non-matching messages are still billed as published at the topic level, but they do not generate SQS messages, Lambda invocations, or SQS queue storage costs downstream.
Filter scope: message attributes vs message body
The filter policy scope determines what SNS evaluates. Set via the FilterPolicyScope subscription attribute:
| Scope | What is evaluated | When to use |
|---|---|---|
MessageAttributes (default) | The MessageAttributes map on the Publish call | Publisher is yours and can add attributes; preferred — no body parse, schema-decoupled |
MessageBody | JSON body of the published message; top-level fields only | Publisher is a third party you cannot modify to add attributes |
For AliveMCP, the probe worker controls the publish call, so it sets explicit MessageAttributes:
await snsClient.send(new PublishCommand({
TopicArn: process.env.STATUS_TOPIC_ARN,
Message: JSON.stringify({ endpointId, status, latencyMs, region }),
MessageAttributes: {
status: { DataType: "String", StringValue: status }, // "healthy"|"degraded"|"unhealthy"
region: { DataType: "String", StringValue: region }, // "us-east-1", "eu-west-1", ...
severity: { DataType: "String", StringValue: getSeverity(status, latencyMs) },
latencyMs: { DataType: "Number", StringValue: String(latencyMs) },
},
}));
Note DataType: "Number" for latencyMs. Numeric filter operators only evaluate attributes published with DataType: "Number". Publishing latency as a String attribute and then writing a numeric filter policy results in the filter never matching — a silent failure that routes no events to the queue regardless of the latency value.
String and numeric operators
String operators apply to String-typed attributes or string-valued body fields:
| Operator | Policy JSON | Matches |
|---|---|---|
| Exact match | ["unhealthy"] | Value exactly equals unhealthy |
| OR (multiple values) | ["degraded","unhealthy"] | Value is either degraded or unhealthy |
| Prefix | [{"prefix":"eu-"}] | Value starts with eu- |
| Suffix | [{"suffix":"-prod"}] | Value ends with -prod |
| Anything-but (exclusion) | [{"anything-but":["healthy"]}] | Anything except healthy — always use array syntax |
| Exists | [{"exists":true}] | Named attribute present in the message |
| Not exists | [{"exists":false}] | Named attribute absent from the message |
| Case-insensitive exact | [{"equals-ignore-case":"UNHEALTHY"}] | Case-insensitive exact match |
Numeric operators apply to Number-typed attributes:
| Operator | Policy JSON | Matches |
|---|---|---|
| Equal | [{"numeric":["=",200]}] | Value equals 200 |
| Greater than | [{"numeric":[">",5000]}] | Value > 5000 |
| Range (between) | [{"numeric":[">",1000,"<",5000]}] | 1000 < value < 5000 |
| Not equal | [{"numeric":["!=",200]}] | Value ≠ 200 |
AND and OR semantics — the most common confusion
Every team hits this eventually. The rules are:
- OR: Multiple values within one key's array.
"status": ["degraded","unhealthy"]matches if status is either value. - AND: Multiple top-level keys in the filter policy object. Every key condition must be satisfied.
// Subscription 1: critical alerts — European endpoints, non-healthy status
// (status is "degraded" OR "unhealthy") AND (region starts with "eu-")
{
"status": ["degraded", "unhealthy"],
"region": [{ "prefix": "eu-" }]
}
// Subscription 2: SLO latency breach — any region, any status, latency > 5 seconds
{
"latencyMs": [{ "numeric": [">", 5000] }]
}
// Subscription 3: ops pager — high latency AND critical/high severity
{
"latencyMs": [{ "numeric": [">", 3000] }],
"severity": ["high", "critical"]
}
The third filter: a message with latencyMs=4500 (matches >3000) but severity="low" (does not match ["high","critical"]) will not be delivered to that subscription. AND semantics require all conditions to match.
Limit: 5 KB, 5 attributes, 150 values per attribute
Each subscription has a 5 KB filter policy size limit, a maximum of 5 AND conditions (top-level keys), and a maximum of 150 values per key's array. These limits are per subscription — you can create multiple subscriptions to the same queue with different filter policies if you need more complex routing.
The 15-minute propagation delay after SetSubscriptionAttributes is the most operationally dangerous property. Testing a filter policy change immediately after applying it will often show the old behavior. When debugging filter policies in staging, wait the full 15 minutes before concluding the policy is wrong.
Pattern 3 — FIFO Topics: Per-Endpoint Ordered State Transitions
When ordering matters and when it does not
Standard SNS topics deliver at-least-once with best-effort ordering. For most probe result events — independent snapshots of endpoint health that are each self-contained — out-of-order delivery is harmless. If a healthy result for endpoint A arrives before a degraded result for endpoint A, but both are processed within seconds, the downstream state reflects the latest state after both are processed.
The ordering invariant breaks when consumers implement a state machine over a sequence of events. An MCP endpoint monitoring system that transitions through healthy → degraded → unhealthy → recovering → healthy produces incorrect alert states if the state machine receives events out of order. If unhealthy arrives before degraded, a consumer that suppresses alerts for repeated same-state events may suppress the degraded transition entirely, producing a gap in the alert history.
FIFO topics enforce strict delivery order within each MessageGroupId and deduplicate within a 5-minute window, at the cost of subscriber type restrictions and throughput limits.
FIFO vs Standard: the tradeoff table
| Property | Standard Topic | FIFO Topic |
|---|---|---|
| Message ordering | Best-effort | Strict per MessageGroupId |
| Delivery semantics | At-least-once (duplicates possible) | Exactly-once within 5-minute dedup window |
| Throughput | 30,000 msg/s per topic | 300 msg/s per group; 3,000 if batched |
| Allowed subscriber types | SQS, Lambda, HTTP/S, email, SMS, mobile push | SQS FIFO, Lambda only |
| Cross-region delivery | Supported | Not supported — same-region only |
| Name format | Any valid name | Must end in .fifo |
MessageGroupId granularity: why per-endpoint is the right choice
Every message published to a FIFO topic requires a MessageGroupId. Messages in the same group are delivered in strict publish order. Choosing the granularity of your GroupId has a direct impact on throughput and blast radius:
| Granularity | MessageGroupId example | Throughput cap | Blast radius |
|---|---|---|---|
| Per-endpoint (recommended) | endpoint-{endpointId} | 300 msg/s per endpoint group — effectively unlimited across thousands of endpoints | A blocked group delays only that endpoint's events |
| Per-region | region-us-east-1 | 300 msg/s for all endpoints in that region | A slow consumer blocks the entire region's events |
| Global single group | global | 300 msg/s total pipeline throughput | Any blocked message stalls the entire pipeline |
The per-endpoint model is almost always correct for MCP monitoring. The 300 msg/s FIFO limit that seems like a bottleneck is per message group — with 10,000 monitored endpoints, you have 10,000 independent groups each with their own 300 msg/s budget.
await client.send(new PublishCommand({
TopicArn: "arn:aws:sns:us-east-1:123456789012:mcp-status.fifo",
MessageGroupId: `endpoint-${endpointId}`, // per-endpoint ordering scope
MessageDeduplicationId: `${endpointId}-${probeRunId}`, // explicit dedup ID
Message: JSON.stringify({ endpointId, status, probeRunId, probeAt: new Date().toISOString() }),
MessageAttributes: {
status: { DataType: "String", StringValue: status },
},
}));
Deduplication: explicit ID vs content-based
Two approaches, with a non-obvious trap in the content-based path:
| Method | How it works | Risk |
|---|---|---|
Explicit MessageDeduplicationId | Publisher provides a unique string per logical event; same string within 5 min = duplicate, silently dropped | None if keyed on {endpointId}-{probeRunId} |
ContentBasedDeduplication: true | SNS hashes the message body; identical body within 5 min = duplicate | Two consecutive healthy probes with identical JSON payloads — same status, same latency — produce only one delivery. Include a timestamp or probeRunId in the body, or use explicit dedup IDs. |
Monitoring FIFO delivery: the silent stall pattern
The blocking property of FIFO queues means a single unprocessable message can silently delay all subsequent events in the same message group. If an endpoint's probe-result consumer fails to process message N and moves it to the DLQ, all subsequent probe results for that endpoint queue behind message N+1. The main queue depth appears non-zero for that group while the DLQ has one message.
AliveMCP detects this indirectly: if the probe result timestamp in the status page diverges from the real probe time by more than the expected processing lag, the consumer is likely blocked on a DLQ item. The direct detection is a CloudWatch alarm on FIFO DLQ depth:
# DLQ depth alarm — any item in FIFO DLQ = a message group is blocked
aws cloudwatch put-metric-alarm \
--alarm-name "mcp-status-fifo-dlq-blocked" \
--namespace AWS/SQS \
--metric-name ApproximateNumberOfMessagesVisible \
--dimensions Name=QueueName,Value=mcp-status-dlq.fifo \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--evaluation-periods 1 \
--period 60 \
--statistic Sum \
--alarm-actions arn:aws:sns:us-east-1:123456789012:ops-alerts
Pattern 4 — Per-Subscription DLQ and Fast-Fail Delivery Policy: Making Silent Failures Visible
The fundamental visibility problem with SNS
The SNS Publish API always returns HTTP 200 if the message was accepted by the topic — regardless of what happens to downstream delivery. A probe worker that publishes a result successfully has no way to know from the Publish response whether the alerting queue received the message, whether the Lambda subscriber was invoked, or whether the delivery failed and SNS entered its internal retry loop.
This means three classes of production failures are invisible by default:
- SQS queue resource policy missing or wrong — SNS silently retries, eventually routes to subscription DLQ
- Lambda subscriber under concurrency throttle — SNS retries, may exhaust and route to DLQ if throttle persists
- FIFO queue DLQ blocking a group — main queue accumulates, no error, no alert unless you instrument it
Two mechanisms close this gap: delivery status logging (makes per-delivery outcomes visible in CloudWatch Logs) and per-subscription DLQ with custom retry policy (provides a last-resort recovery path and an alarming surface).
SNS subscription DLQ vs SQS queue DLQ — a critical distinction
Both types are SQS queues. Their difference is what failure they capture and where they are configured:
| Property | SNS subscription DLQ | SQS queue DLQ |
|---|---|---|
| Configured on | The SNS subscription (RedrivePolicy) | The source SQS queue (RedrivePolicy) |
| Captures | Delivery infrastructure failures — SNS could not write to the subscriber endpoint after all retries | Consumer logic failures — SQS message received but Lambda could not process it |
| Failure type | Endpoint unreachable, access denied, Lambda throttle | Consumer bug, timeout, bad data |
For an SNS→SQS fan-out pipeline, you need both types. A missing queue resource policy produces SNS subscription DLQ entries. A consumer bug produces SQS DLQ entries. Looking only at the SQS DLQ leaves delivery infrastructure failures invisible.
The 23-day default retry problem
SNS's default delivery retry policy retries a failed SQS delivery 100,015 times over 23 days before routing to the subscription DLQ. The breakdown: 3 immediate retries (default numRetries=3), followed by 100,000 retries at the max delay (numMaxDelayRetries=100000, default maxDelayTarget=20s). The result: a message with a misconfigured queue resource policy sits in SNS retry hell for 23 days while your alerting pipeline silently discards every probe result for that endpoint.
For MCP monitoring, a probe result that cannot be delivered within a few minutes is stale and useless for alerting. Configure a fast-fail policy:
aws sns set-subscription-attributes \
--subscription-arn arn:aws:sns:us-east-1:123456789012:mcp-probe-results:uuid \
--attribute-name DeliveryPolicy \
--attribute-value '{
"healthyRetryPolicy": {
"numRetries": 8,
"numNoDelayRetries": 3,
"numMinDelayRetries": 2,
"numMaxDelayRetries": 0,
"minDelayTarget": 5,
"maxDelayTarget": 60,
"backoffFunction": "exponential"
}
}'
This policy exhausts in approximately 3 minutes: 3 immediate retries, 2 at 5 seconds, 3 at exponential backoff up to 60 seconds, then route to DLQ. Zero post-backoff retries — numMaxDelayRetries: 0 is the key setting that eliminates the 23-day tail.
CDK: wiring the subscription DLQ
import * as sns from "aws-cdk-lib/aws-sns";
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as subscriptions from "aws-cdk-lib/aws-sns-subscriptions";
import * as iam from "aws-cdk-lib/aws-iam";
import { Duration, Stack } from "aws-cdk-lib";
// Create DLQ for SNS delivery failures (distinct from the main queue's consumer DLQ)
const snsDeliveryDlq = new sqs.Queue(this, "SnsDeliveryDlq", {
queueName: "sns-delivery-failures",
retentionPeriod: Duration.days(14),
});
// Resource policy: SNS must write to the DLQ on delivery failure
// Use ArnLike (not ArnEquals) because the subscription ARN is not known at stack creation time
snsDeliveryDlq.addToResourcePolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
principals: [new iam.ServicePrincipal("sns.amazonaws.com")],
actions: ["sqs:SendMessage"],
resources: [snsDeliveryDlq.queueArn],
conditions: {
ArnLike: {
"aws:SourceArn": `arn:aws:sns:${Stack.of(this).region}:${Stack.of(this).account}:*`,
},
},
}));
// CDK v2.x: deadLetterQueue prop on SqsSubscription (preferred — no escape hatch needed)
topic.addSubscription(
new subscriptions.SqsSubscription(alertingQueue, {
rawMessageDelivery: true,
deadLetterQueue: snsDeliveryDlq, // sets RedrivePolicy on the subscription automatically
})
);
Delivery status logging: the only way to see why a delivery failed
Without delivery logging, SNS delivery failures produce no CloudWatch metrics, no logs, and no errors. Enable per-protocol delivery logging on the topic by attaching an IAM role that allows SNS to write to CloudWatch Logs:
// IAM role for delivery logs
const snsLoggingRole = new iam.Role(this, "SnsLoggingRole", {
assumedBy: new iam.ServicePrincipal("sns.amazonaws.com"),
inlinePolicies: {
Logging: new iam.PolicyDocument({
statements: [new iam.PolicyStatement({
actions: ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents",
"logs:DescribeLogGroups","logs:DescribeLogStreams"],
resources: ["*"],
})],
}),
},
});
// Attach to the SNS topic (CDK escape hatch — L2 does not expose these props)
const cfnTopic = topic.node.defaultChild as sns.CfnTopic;
cfnTopic.sqsSuccessFeedbackRoleArn = snsLoggingRole.roleArn;
cfnTopic.sqsFailureFeedbackRoleArn = snsLoggingRole.roleArn;
cfnTopic.sqsSuccessFeedbackSampleRate = "5"; // log 5% of successes; 100% of failures always logged
Logs appear in /aws/sns/{region}/{account-id}/{topic-name}. Each log event contains status (SUCCESS or FAILURE), destination (queue ARN), providerResponse (the SQS error message when status is FAILURE), and dwellTimeMs (time SNS spent on this delivery attempt).
Reading DLQ messages: the deadLetterReason taxonomy
When SNS routes a message to the subscription DLQ after exhausting retries, the SQS message has a deadLetterReason message attribute that identifies the root cause:
| deadLetterReason | Root cause | Fix |
|---|---|---|
NO_ENDPOINT_REACHABLE | Queue does not exist, wrong ARN, or queue deleted after subscription created | Verify queue ARN in subscription attributes; check if queue was accidentally deleted |
ACCESS_DENIED | SQS queue resource policy does not allow sqs:SendMessage from this SNS topic | Add or correct the queue resource policy with ArnEquals source condition |
LAMBDA_ACCESS_DENIED | Lambda resource-based policy does not allow lambda:InvokeFunction from SNS | Add SNS as allowed principal in Lambda resource policy |
LAMBDA_RUNTIME_EXCEPTION | Lambda threw an unhandled exception on every delivery attempt | Check Lambda function logs; fix the exception or add error handling |
KMS_ACCESS_DENIED | Queue's CMK does not grant SNS permission to encrypt messages | Add kms:GenerateDataKey and kms:Decrypt to the CMK key policy for sns.amazonaws.com |
Cross-Pattern Failure Modes
| Symptom | Pattern | Cause | Fix |
|---|---|---|---|
| Publish succeeds; queue receives nothing | Fan-out | Missing or unconditioned SQS resource policy — SNS silently retries then discards | Add sqs:SendMessage allow with ArnEquals: aws:SourceArn: <topic-arn> to queue resource policy |
Consumer sees double-encoded JSON (JSON.parse(msg.Body).Message is a string) | Fan-out | Raw message delivery is off; consumer assumes raw format | Set rawMessageDelivery: true on subscription, or update consumer to unwrap SNS envelope |
Cross-account delivery fails with KMSAccessDeniedException | Fan-out | Destination queue uses aws/sqs managed key — managed keys cannot be shared cross-account | Switch queue to a CMK; add key policy granting kms:GenerateDataKey to sns.amazonaws.com |
| Subscriber receives no messages despite correct filter intent | Filter policies | DataType mismatch — latency published as String but filter uses numeric operator, or FilterPolicyScope defaults to attributes when policy targets body fields | Verify DataType: "Number" for numeric attributes at publish; set FilterPolicyScope: MessageBody for body-based filters |
| Filter policy change has no effect immediately after applying | Filter policies | SNS propagates filter policy changes asynchronously — up to 15 minutes | Wait 15 minutes before testing; do not assume immediate effect |
Subscribe fails with InvalidParameter: Invalid parameter: Topic | FIFO topics | Trying to add HTTP/S, email, or SMS subscription to a FIFO topic | Only SQS FIFO queues and Lambda can subscribe to FIFO topics |
| Messages in FIFO DLQ; consumer stuck; all subsequent events in group delayed | FIFO topics | One message failed processing and exhausted maxReceiveCount; FIFO queue head-of-line blocking | Alarm on FIFO DLQ depth > 0; investigate and redrive or discard the blocking message |
| Duplicate publish returns success; message silently dropped; consumer count diverges | FIFO topics | Duplicate MessageDeduplicationId within 5-minute window — deduplicated, no error returned | Include per-probe-run UUID in dedup ID: {endpointId}-{probeRunId} |
| SNS fails to write to subscription DLQ on delivery failure | DLQ + retry | RedrivePolicy not set on subscription, or DLQ resource policy denies SNS write | Verify subscription attributes; check DLQ resource policy ArnLike source condition |
| Messages reach DLQ after 23 days — alerts worthless by then | DLQ + retry | Default retry policy has numMaxDelayRetries: 100000 | Set custom delivery policy with numMaxDelayRetries: 0 and 8 total retries (exhausts in ~3 min) |
| Delivery failures invisible — no CloudWatch metric, no log, no error | DLQ + retry | Delivery status logging not enabled | Set SqsSuccessFeedbackRoleArn and SqsFailureFeedbackRoleArn on topic; verify IAM role trust allows SNS |
FIFO topic + SQS subscriber: InvalidParameter on subscribe | FIFO topics | SQS queue is not a FIFO queue (missing .fifo suffix and fifo: true in CDK) | FIFO topics require FIFO SQS queues as subscribers; recreate queue as FIFO |
Sequencing Guidance: Build in Layers
The four patterns compose naturally into a layered architecture. Build in this order:
- Fan-out first. Establish the topology: one SNS topic, three or four SQS queues, correct resource policies. This is the structural layer everything else builds on. Do not add filtering or FIFO semantics until the basic fan-out is verified working end-to-end with a test message.
- Filter policies second. Once the topology is confirmed, add subscription filter policies to reduce downstream processing cost and noise. Test each filter by publishing test messages with known attributes and verifying only the expected queues receive them — wait the full 15-minute propagation window before concluding a policy is wrong.
- FIFO topics third (only if ordering matters). Migrate the main status topic to FIFO if the downstream state machine requires ordered delivery. This is an irreversible change to the topic type and affects all subscriptions simultaneously. Most MCP monitoring workloads do not need FIFO — per-endpoint per-minute probe results are rarely order-sensitive if the timestamp is in the payload.
- DLQ + delivery logging last — but not optional. A monitoring pipeline without delivery visibility is itself an unmonitored system. Add per-subscription DLQs, the fast-fail delivery policy, and delivery status logging before declaring the pipeline production-ready. The DLQ alarm on non-empty depth is the signal that the monitoring infrastructure has a blind spot.
For AliveMCP's probe architecture — Lambda probe workers publishing results every 60 seconds per endpoint — the practical deployment is: standard SNS topic (not FIFO, since probe timestamps in the payload make ordering redundant), fan-out to alerting/analytics/archiving queues with subscription filter policies, and per-subscription DLQ with 8-retry fast-fail policy on all three subscriptions. The alerting queue subscription uses status: ["degraded","unhealthy"] and severity: ["high","critical"] AND filter; the analytics queue has no filter; the archiving queue has no filter but enables envelope delivery for MessageId-based deduplication in the S3 writer.