Messaging

AWS Intermediate/Advanced

Chapter 9 · Messaging and Events: SQS, SNS, EventBridge and Step Functions

AWS Intermediate/Advanced 8 put an API in front of a Lambda function that answers a request and finishes. Real systems also do work that should not make the caller wait, must survive a failure, or must reach several places. Four services cover that: SQS holds messages until a consumer takes them, SNS pushes one message to many subscribers, EventBridge routes events to targets by their content, and Step Functions runs a multi-step workflow with retries and error paths. This chapter explains each from the documentation and then tests what can be tested on a laptop. Event-driven architecture as a design idea is not re-taught here; the AWS-specific rules and limits are.

How every example in this chapter was verified
There is no AWS account and no AWS credentials on the machine that wrote this course, so no real SQS queue, SNS topic, EventBridge bus or state machine was used. The course outline said to use an emulator only if it can be shown to be adequate, so a good part of this chapter is that test.
  • Run here (level 1): the moto 5.2.4 emulator in server mode (SQS, SNS, EventBridge) driven through boto3 1.43.112; Step Functions Local (AWS's Docker image amazon/aws-stepfunctions-local) with a mock configuration; a real Lambda Python 3.13 container through the SAM CLI from chapter 8; cfn-lint 1.57.2; and four small programs I wrote: an EventBridge pattern matcher, an SQS consumer, a cross-resource template checker and a cost sketch. The emulators are checked against the documentation, not the other way round.
  • From the documentation, not run (level 3): every statement about what the real services do. The pages were read on 2026-10-13 through a page-fetch tool that summarises pages; some pages came back empty or as general knowledge, and each of those is named where it matters (SNS fan-out, SQS and SNS price tables).
  • Needs an account, not run (level 2): the same boto3 code against real AWS, and real deployments of the template.
LocalStack's free edition was retired in March 2026 (AWS Intermediate/Advanced course outline) and I did not try the young alternatives. AWS itself calls Step Functions Local "unsupported" and without "feature parity".

Which Service for Which Job?

The comparison below uses only facts from the pages I read; the last column is where I add a judgement, marked as such.

SQSSNSEventBridgeStep Functions
ShapeA queue consumers pull fromA topic that pushes to subscribersA bus with rules that match event contentA state machine that runs steps
Keeps messagesDefault 4 days; 60 s to 14 daysDelivers; archive and replay exist for FIFO topics (page not read)Retries a failed delivery for 24 hours and up to 185 times by default, then drops it unless you configure a dead-letter queueStandard: execution history for 90 days after it closes
Selecting what a receiver getsEverything in the queueSubscription filter policy on attributes or bodyEvent pattern on any field of the eventChoice states in the definition
Failure handlingVisibility timeout, then retry; dead-letter queue after maxReceiveCountNot covered hereRetry policy and dead-letter queue per targetRetry and Catch per state
DeliveryAt least once (standard); FIFO removes duplicates inside 5 minutesNot readNot read beyond retriesStandard: exactly once per execution; Express: at least once (async) or at most once (sync)
Good fit (judgement, not tested)Buffering work for a consumer that may be slow or downOne event, several independent reactionsRouting many event types to many targets without codeOrdered steps with retries, waits and branches

A common chain, and the one this chapter builds, is: something publishes to an SNS topic; a filtered subscription puts matching messages on an SQS queue; a Lambda function drains the queue; messages that keep failing land in a dead-letter queue; and a state machine handles the multi-step part with retries. EventBridge sits beside it for events that come from AWS itself or from other applications.

Can the Emulator Be Trusted?

I ran the same questions against the documentation and the emulators and counted the agreements. The result is mixed, and it decides how the rest of the chapter uses the emulator: good for behaviour, poor for limits, and with one real defect.

Area (emulator)What I checkedAgreed with the docs
SQS behaviour (moto 5.2.4)Visibility timeout, change visibility, dead-letter queue, FIFO group, deduplication, in-flight blocking, long polling10 of 10
SQS limits (moto)12 documented limits, one value just inside and one just outside6 of 12
SNS (moto)Fan-out, envelope versus raw delivery, filter-policy scopes, 12 operators6 of 6 groups
EventBridge patterns (moto)43 cases built from the docs' examples42 of 43 (a real defect on the time field)
Step Functions retries (Step Functions Local)9 retry and catch scenarios, delays measured9 of 9
Step Functions definition checks (Step Functions Local)22 invalid definitionsrejected 21

The details follow in each service's section. The practical reading: use the emulators to test how your code behaves (does my consumer cope with a redelivery, does my pattern match my event), and use something else for limits and for validity (cfn-lint, your own checks, and ultimately the real service).

SQS: Queues, Visibility and Dead Letters

The rules, from the documentation

  • Visibility timeout. A received message "remains in the queue but becomes temporarily invisible"; the default is 30 seconds, the range 0 seconds to 12 hours. If you do not delete it before the timeout, it becomes visible again. ChangeMessageVisibility can extend or shorten the timeout, and setting it to 0 makes the message available at once. "Extending the timeout doesn't reset this 12-hour limit."
  • At-least-once. "There's no absolute guarantee that a message won't be delivered more than once during the visibility timeout period." Design consumers so that seeing a message twice is harmless.
  • Dead-letter queues. A redrive policy gives a maxReceiveCount: "the number of times a consumer can receive a message from a source queue before it is moved to a dead-letter queue". The dead-letter queue must be in the same account and Region. For a standard queue the message keeps its original enqueue timestamp, so "it is a best practice to always set the retention period of a dead-letter queue to be longer than the retention period of the original queue"; for FIFO queues the timestamp resets. The page warns against a dead-letter queue on a FIFO queue where the exact order must survive.
  • Polling. Short polling (the default) "queries a subset of servers" and a call "might not return all of your messages"; long polling (a wait time above 0, maximum 20 seconds) queries all servers and returns as soon as a message is available.
  • FIFO. MessageGroupId is required; messages in a group are delivered in order, and while one is in flight "subsequent messages in that group are not made available until the in-flight message is either deleted or the visibility timeout expires". A resend with the same deduplication ID inside the 5-minute interval adds nothing; content-based deduplication hashes the body but "not the attributes of the message". Each partition is limited to 300 transactions per second per action (3,000 messages per second with batching of 10) unless high-throughput mode is on.
  • Limits. Message size 1 byte to 1,048,576 bytes (1 MiB); retention 60 seconds to 14 days; delay 0 to 15 minutes; 10 messages per batch; 10 message attributes; group ID up to 128 characters; batch entry ID up to 80 characters (alphanumerics, hyphen, underscore); about 120,000 in-flight messages on a standard queue (OverLimit under short polling, silently nothing under long polling).
  • Price (partial). The page I read gave no price table. It says every action is a request, each 64 KB chunk of a payload is billed as one request (a 1 MiB message is 16 requests), and the first million requests a month are free. FIFO and standard requests have different rates that I could not read.

Run here: behaviour against the emulator

Each check sends real SQS API calls to moto and compares the result with the sentence above. Two mistakes of mine are worth keeping: my first FIFO ordering check asked for 10 messages in one call and got the whole group back at once (g1-1, g1-2 and g2-1), which looked like a failure; the page I read does not say whether one call may return several messages of a group, so I split it into an unscored information line and a one-message-at-a-time check that matches the quoted sentence.

py sqs_behaviour.py # condensed
ok visibility timeout hides a received message, then it reappears -> 1st receive 1 message, immediate 2nd receive 0, after 2.4 s receive 1 (ReceiveCount 2) ok ChangeMessageVisibility to 0 makes the message available at once -> receive right after: 1 message ok after maxReceiveCount receives the message moves to the dead-letter queue -> receives that returned the message (3 allowed): [1, 1, 1, 0, 0, 0]; message in DLQ afterwards: ['poison'] ok FIFO send without MessageGroupId fails -> MissingParameter ok FIFO drops a resend with the same deduplication id inside the 5-minute interval -> two sends with the same id, receive returned ['a'] ok content-based deduplication hashes the body only, not attributes -> same body with different attributes sent twice, receive returned 1 info one receive asking for up to 10 messages returned ['g1-1', 'g1-2', 'g2-1'] (the page I read does not say whether a single call may return several messages of one group, so this is not scored) ok FIFO: while a group's message is in flight, later messages of that group are not delivered -> one message per receive, nothing deleted: [['g1-1'], ['g2-1'], []] ok FIFO: after the in-flight message is deleted the next one of its group is delivered -> after deleting g1-1 a receive returned ['g1-2'] ok long polling waits for the wait time on an empty queue -> WaitTimeSeconds=3 on an empty queue returned after 3.0 s with 0 messages ok long polling returns as soon as a message arrives -> message sent after 1 s, receive with WaitTimeSeconds=10 returned after 1.0 s with 1 message 10 of 10 behaviour checks agree with the documentation

All ten scored checks agreed. The limits are another matter. I sent one value just inside each documented limit and one just outside:

py sqs_quotas.py
limit (docs) inside outside message size max 1,048,576 bytes 1,048,576 byte accepted 1,048,577 byte rejected (InvalidParameterValue) visibility timeout max 12 hours (43,200 s) 43200 accepted 43201 accepted <-- differs from the docs retention min 60 s 60 accepted 59 accepted <-- differs from the docs retention max 1,209,600 s (14 days) 1209600 accepted 1209601 accepted <-- differs from the docs message timer (delay) max 15 minutes 900 accepted 901 rejected (InvalidParameterValue) long polling wait max 20 s 20 (empty queu accepted 21 rejected (InvalidParameterValue) batch max 10 messages 10 entries accepted 11 entries rejected (AWS.SimpleQueueService.TooManyEntriesInBatchRequest) up to 10 message attributes 10 attributes accepted 11 attributes accepted <-- differs from the docs FIFO MessageGroupId max 128 characters 128 accepted 129 accepted <-- differs from the docs batched message ID max 80 characters, [A-Za-z0-9_-] 80 accepted 81 rejected (AWS.SimpleQueueService.InvalidBatchEntryId) batched message ID only alphanumerics, - and _ 'a-b_1' accepted 'a b!' rejected (AWS.SimpleQueueService.InvalidBatchEntryId) message content: control character #x1 not allowed 'plain text' accepted text with U+00 accepted <-- differs from the docs 6 of 12 limits enforced exactly as documented

moto enforced only 6 of the 12 limits. It accepted a visibility timeout of 43,201 seconds, retention of 59 and 1,209,601 seconds, 11 message attributes, a 129-character group ID and a control character in the body. A test suite on this emulator would pass code that the real service rejects. This is why the template tests later in the chapter use cfn-lint, which caught the queue-attribute ranges.

At-least-once in practice: a slow consumer

Run here: a queue with a 2-second visibility timeout, a message that takes 3.5 seconds to process, and two workers. The first worker is still busy when the message reappears and the second takes it:

def worker(q, name, process_seconds, handler, stop, log): while not stop.is_set(): r = sqs.receive_message(QueueUrl=q, MaxNumberOfMessages=1, WaitTimeSeconds=1, AttributeNames=["ApproximateReceiveCount"]).get("Messages", []) for m in r: body = json.loads(m["Body"]) done = handler(name, body, process_seconds, m) log.append((round(time.time() - T0, 1), name, body["order"], "receive#" + m["Attributes"]["ApproximateReceiveCount"], done)) try: sqs.delete_message(QueueUrl=q, ReceiptHandle=m["ReceiptHandle"]) except Exception as e: log.append((round(time.time() - T0, 1), name, body["order"], "delete failed", type(e).__name__)) EFFECTS = [] def naive(name, body, secs, m): time.sleep(secs) EFFECTS.append(body["order"]) # the side effect (charge the card) return "charged" PROCESSED, LOCK = set(), threading.Lock() def idempotent(name, body, secs, m): with LOCK: if body["order"] in PROCESSED: return "skipped (already processed)" PROCESSED.add(body["order"]) time.sleep(secs) EFFECTS.append(body["order"]) return "charged"
py consumer_demo.py
-- naive consumer (visibility timeout 2 s, processing takes 3.5 s, two workers) t= 3.5s worker-1 order A-1001 receive#1 charged t= 5.6s worker-2 order A-1001 receive#2 charged side effects performed: 2 -> ['A-1001', 'A-1001'] -- idempotent consumer (visibility timeout 2 s, processing takes 3.5 s, two workers) t= 2.1s worker-2 order A-1001 receive#2 skipped (already processed) t= 3.5s worker-1 order A-1001 receive#1 charged side effects performed: 1 -> ['A-1001'] naive consumer charged order A-1001 2 times; idempotent consumer 1 time(s)

The naive consumer charged order A-1001 twice. The idempotent one saw the repeat at 2.1 seconds, recognised the order ID and skipped it, so the charge happened once. Here the "already processed" check is an in-memory set; in a real system it must be shared and atomic (a conditional write to a database is the usual tool; not run here). A first version of this demo crashed because I forgot to ask for the ApproximateReceiveCount attribute: the workers died after the side effect and before the delete, so the first numbers were meaningless until I fixed and re-ran it.

SNS: Fan-Out and Filter Policies

From the documentation: "By default, an Amazon SNS topic subscriber receives every message that's published to the topic." A subscription can carry a filter policy; with scope MessageAttributes it is matched against the attributes, with MessageBody against the message's JSON body. "If any single property in this policy doesn't match an attribute assigned to the message, the policy rejects the message." Raw message delivery to SQS strips the SNS metadata so "the message is sent as is" (otherwise the receiver gets a JSON envelope); with raw delivery to an SQS queue at most 10 message attributes can be sent, and messages with more are discarded. The pricing page says SNS does not charge for per-message delivery to SQS and Lambda, but gave no price table, so the publish price is not stated here.

Run here: the page's own example message and its two policies (one that should accept it, one that should reject it because it requires an encrypted attribute the message does not have), in both scopes, plus twelve operators one at a time:

py sns_tests.py # condensed
ok one publish reaches every subscribed queue -> queue 1 got 1, queue 2 got 1 ok default delivery wraps the message in an SNS JSON envelope -> envelope keys: Message, MessageId, Signature, SignatureVersion, SigningCertURL, Subject, Timestamp, TopicArn, Type, UnsubscribeURL ok raw message delivery sends the message 'as is' -> body: 'This is a test message.' ok filter policy scope MessageAttributes: docs' accepting policy, rejecting policy, no policy -> accepting policy delivered 1, rejecting policy delivered 0, no policy delivered 1 ok filter policy scope MessageBody: docs' accepting policy, rejecting policy, no policy -> accepting policy delivered 1, rejecting policy delivered 0, no policy delivered 1 operator checks on one message (attribute scope): 12 of 12 as expected 6 of 6 groups agree with the documentation (0 operator differences)

moto agreed everywhere, including the anything-but, numeric and exists operators. Two caveats. First, my message delivered to the queue without any queue access policy; the documentation page on fan-out to SQS did not load through my fetch tool (it returned general knowledge instead), so from my own understanding, not from a page I read, the real service needs the queue's policy to allow sns.amazonaws.com to send. The emulator cannot tell you that you forgot it. Second, the template in this chapter includes such a policy and cfn-lint cannot tell if it is missing (see the template section).

EventBridge: Rules and Event Patterns

From the documentation: an event pattern "has the same structure as the events they match" and either matches or does not. All fields in the pattern must match; the values of a field are an array of alternatives (OR); if the event's value is an array, the pattern matches when "the intersection of the event pattern array and the event array is non-empty". The operators page lists, among others: prefix, suffix, equals-ignore-case, anything-but (with values, lists, prefix, suffix, equals-ignore-case and wildcard), numeric, exists, cidr, wildcard (event buses only) and $or. Numbers are compared as 64-bit doubles, exact only up to ±253; a pattern with more than 1,000 $or combinations is rejected; if a key is repeated in a pattern "the last reference will be the one used". Null and empty strings "are not interchangeable". A warning on the page is worth repeating: a rule can trigger the very change it detects and loop, with "higher-than-expected charges and throttling".

Delivery, from the docs: if a target cannot be reached, EventBridge "retries sending the event for 24 hours and up to 185 times with an exponential back off and jitter" by default (both settable per target); after that "the event is dropped" unless the target has a dead-letter queue. Price (page, Region not stated, from worked examples): custom events $1.00 per million; AWS management events are free to ingest; Scheduler invocations are free for 14,000,000 a month and then $1.00 per million.

Run here: a reference matcher and the emulator

Trying to test patterns, I found that moto's TestEventPattern is a stub: it returned an empty 200 reply for a matching pattern and for a non-matching one alike. The test therefore had to use the real path: a rule on a fresh bus, an SQS queue as target, put_events, and a look at whether a message arrived. As a second opinion I wrote a reference matcher that implements only what the pages above state:

def _field(conds, v): vals = v if isinstance(v, list) else [v] if isinstance(v, list) and not v: vals = [MISSING] if any(isinstance(c, dict) and "exists" in c for c in conds) else [] return any(_op(c, x) for c in conds for x in vals) def matches(pattern, event): for key, sub in pattern.items(): if key == "$or": if not any(matches(p, event) for p in sub): return False continue v = event.get(key, MISSING) if isinstance(event, dict) else MISSING if isinstance(sub, dict): if v is MISSING or not isinstance(v, dict): return False if not matches(sub, v): return False else: if not _field(sub, v): return False return True

Then 43 cases, each with the outcome the docs' example or sentence implies:

py eb_pattern_tests.py
case docs model moto docs' main example: source + detail-type + state True True True same, state running False False False one wrong field among right ones False False False OR: values in one array True True True event array field, non-empty intersection True True True event array field, empty intersection False False False prefix on time True True False <-- prefix, no match False False False anything-but a different string True True True anything-but the actual string False False False anything-but a list containing it False False False anything-but a list without it True True True anything-but prefix, value has the prefix False False False anything-but prefix, value lacks it True True True anything-but equals-ignore-case, other casing False False False suffix .png True True True suffix is case-sensitive False False False suffix with equals-ignore-case True True True anything-but suffix, matching file False False False equals-ignore-case on detail-type True True True prefix with equals-ignore-case True True True exists true, field present True True True exists true, field absent False False False exists false, field absent True True True numeric equals True True True numeric range (10, 20], value 20 True True True numeric range (10, 20], value 10 False False False numeric range (10, 20], value 21 False False False numeric 3.018e2 equals 301.8 True True True numeric against a string value False False False $or, second branch true True True True $or, neither branch true False False False wildcard dir/*.png True True True wildcard dir/*.png, other folder False False False anything-but wildcard */lib/*, path contains /lib/ False False False anything-but wildcard */lib/*, path without it True True True cidr 10.0.0.0/24 inside True True True cidr 10.0.0.0/24 outside False False False empty string matches [""] True True True null matches [null] True True True [""] does not match null False False False [null] does not match an empty string False False False repeated key: the last one is used (raw JSON) True True True 43 cases: reference matcher agrees with the docs on 43, moto agrees on 42; moto rejected the pattern for: none

The reference matcher agreed with the documentation on all 43 cases (so it is a fair second opinion); moto agreed on 42. The one disagreement is a real defect in moto 5.2.4 for something the docs show directly ("time": [{"prefix": "2017-10-02"}] is the docs' own example). I narrowed it down:

py (probe of time-field matching)
prefix on source ('app.orders', prefix 'app.') delivered=1 prefix on detail-type delivered=1 prefix on time ('2017-12-22T18:43:48Z') delivered=0 exact value on time delivered=0 exists on time delivered=1 prefix on a detail string (same text) delivered=1

A prefix on source, on detail-type and on the same text inside detail all matched; an exact value or prefix on the top-level time never did, while exists on time did. moto stores the time exactly as sent, so I could not tell the cause from outside; the lesson is that an emulator can fail on one field and agree on the rest. Not tested: IPv6 addresses, pattern size limits, the wildcard complexity limit, archives and replay, schedules.

Step Functions: Workflows with Retry and Catch

Standard or Express, from the docs: Standard workflows run up to a year, are "exactly-once", keep history for 90 days after closing, and are billed per state transition; Express workflows run up to 5 minutes, are "at-least-once" (asynchronous) or "at-most-once" (synchronous), and are billed by requests, duration and memory. Express workflows do not support the job-run (.sync) or callback (.waitForTaskToken) integration patterns, Distributed Map or activities. The workflow type cannot be changed after creation. Quotas worth knowing: a Standard execution fails if its history reaches 25,000 events; task, state and execution input or output is limited to 256 KiB; names are limited to 80 characters; 1,000,000 open executions per account per Region.

Errors, from the docs. Any state except Pass and Wait can fail, and "Step Functions defaults to failing the entire state machine execution". Task, Parallel and Map states can have Retry (a list of retriers) and Catch. A retrier has ErrorEquals, IntervalSeconds (default 1), MaxAttempts (default 3; 0 means never retry), BackoffRate (default 2.0), an optional MaxDelaySeconds (above 0) and JitterStrategy (FULL or NONE, default NONE). States.ALL "must appear alone in the ErrorEquals array and must appear in the last retrier"; it does not catch States.DataLimitExceeded or States.Runtime; States.TaskFailed matches any known error except States.Timeout. Retries run first and the matching catcher second, and "retries are treated as state transitions" for billing.

Run here: retry timing on Step Functions Local

Step Functions Local calls no real services when a mock configuration scripts each Task's result. The docs describe the file: StateMachines with TestCases, and MockedResponses numbered by attempt ("0-1" is attempts 0 and 1) with Return or Throw; every attempt a retry might make needs a mocked response, or the execution fails. The state machine, the mock file and the results:

def machine(retry, catch=True): task = {"Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "Parameters": {"FunctionName": FN, "Payload.$": "$"}, "Retry": retry, "Next": "Done"} if catch: task["Catch"] = [{"ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "Refund"}] return {"StartAt": "Charge", "States": {"Charge": task, "Done": {"Type": "Succeed"}, "Refund": {"Type": "Pass", "Result": "refunded", "End": True}}}
{ "StateMachines": { "RetryDemo": { "TestCases": { "SucceedAfterTwo": {"Charge": "ThrowTwiceThenOk"}, "AlwaysFails": {"Charge": "AlwaysTransient"}, "OtherError": {"Charge": "Declined"} } }, "CappedRetry": {"TestCases": {"AlwaysFails": {"Charge": "AlwaysTransient"}}}, "UncappedRetry": {"TestCases": {"AlwaysFails": {"Charge": "AlwaysTransient"}}}, "JitterRetry": {"TestCases": {"AlwaysFails": {"Charge": "AlwaysTransient"}}},
py sfn_retry.py
scenario tries waits (s) status states entered 2 transient failures, then success (1 s, x2, max 3) 3 [1.0, 2.0] SUCCEEDED Charge>Done [docs: waits 1 then 2; success] always fails: retries used up, then Catch 4 [1.0, 2.0, 4.0] SUCCEEDED Charge>Refund [docs: 3 retries (waits 1,2,4) then Catch] error name not in ErrorEquals: no retry, Catch 1 [] SUCCEEDED Charge>Refund [docs: no retry; Catch] interval 3, x2, max 3, MaxDelaySeconds 5 4 [3.0, 5.0, 5.0] SUCCEEDED Charge>Refund [docs: waits 3, 5, 5] interval 3, x2, max 3, no cap 4 [3.0, 6.0, 12.0] SUCCEEDED Charge>Refund [docs: waits 3, 6, 12] JitterStrategy FULL, interval 2, x2, max 3 (run 1) 4 [1.0, 3.0, 8.0] SUCCEEDED Charge>Refund [docs: waits random in [0,2], [0,4], [0,8]] JitterStrategy FULL, interval 2, x2, max 3 (run 2) 4 [2.0, 3.0, 1.0] SUCCEEDED Charge>Refund [docs: waits random in [0,2], [0,4], [0,8]] docs' 'retry all except States.Timeout': Timeout thrown 1 [] SUCCEEDED Charge>Refund [docs: States.Timeout never retried] same policy: another error thrown once 2 [1.0] SUCCEEDED Charge>Done [docs: retried once, success]

The waits matched the pages in every scenario: 1 then 2 seconds; 1, 2, 4 when all retries are used before the catcher takes over; 3, 5, 5 with MaxDelaySeconds 5 and 3, 6, 12 without it (the docs' own two examples); an error not listed in ErrorEquals was not retried at all; and the docs' "retry everything except States.Timeout" policy did not retry a timeout and did retry another error. The two JitterStrategy FULL runs gave different waits (1, 3, 8 and 2, 3, 1 seconds), each inside its documented range of 0 to 2, 0 to 4 and 0 to 8; both runs produced whole seconds, which the page does not promise. The execution status was SUCCEEDED after a catch because the fallback state was a Pass state: a handled failure is a success as far as the execution status goes, so alarms on status alone will miss it.

Cost sketch

From the pricing page (a table was missing from the text, so these come from its examples): Standard is $0.000025 per state transition in US East (N. Virginia), with 4,000 free a month, and "each retry will be charged as an additional state transition". Express is $1.00 per million requests (N. Virginia) plus $0.00001667 per GB-second (Region not stated). Counting my own transitions as states entered plus retries (AWS's count may differ by one):

py cost9.py
execution path (from the retry runs) states retries transitions per 1M executions 2 transient failures, then success (1 s, x2, max 3) 2 2 4 $100.00 always fails: retries used up, then Catch 2 3 5 $125.00 error name not in ErrorEquals: no retry, Catch 2 0 2 $50.00 same policy: another error thrown once 2 1 3 $75.00 free tier: 4,000 transitions a month = 1,000 executions of a 4-transition path one more retry on every execution of the 2-state path adds 50% to its transition count EventBridge custom events: 1,000,000 events x $1.00/M = $1.00 (Region not stated on the page)

A path with three retries costs 2.5 times a path without any, so retries are not free; and the same page's free tier covers only 1,000 executions of a four-transition path.

What validates a state machine definition?

I changed one thing at a time in a definition with a Task, Choice, Wait, Parallel, Map, Succeed and Fail state, and asked two checkers: Step Functions Local (it validates on CreateStateMachine) and cfn-lint (the definition in a CloudFormation DefinitionString). The "expect" column says what the docs or the ASL (Amazon States Language) specification say; for the specification I rely on my own reading, because I did not fetch it this session:

py asl_mutations.py
change expect Step Functions Local cfn-lint 1.57.2 (control) the definition as written valid accepted accepted state Type 'Task' -> 'Taks' invalid rejected rejected E3601 Next points to a state that does not exist invalid rejected accepted StartAt names a missing state invalid rejected rejected E3601 StartAt removed invalid rejected rejected E3601 an unreachable extra state invalid rejected accepted Choice rule without Next invalid rejected rejected E1101 Choice without a Default valid accepted accepted Task without Resource invalid rejected rejected E3601 Parallel without Branches invalid rejected rejected E3601 a state with both Next and End invalid rejected rejected E3601 Succeed state given a Next invalid rejected rejected E3601 Fail state given a Next invalid rejected rejected E3601 Wait with Seconds and Timestamp invalid rejected accepted Wait with neither Seconds nor Timestamp invalid rejected accepted Map MaxConcurrency 'two' invalid rejected rejected E3601 Retry in a Pass state invalid rejected rejected E1101 Retry MaxAttempts -1 invalid rejected rejected E1101 Retry IntervalSeconds 0 invalid accepted accepted Retry BackoffRate 0.5 unknown rejected accepted Retry JitterStrategy 'HALF' invalid rejected accepted Retry MaxDelaySeconds 0 invalid rejected accepted States.ALL not last in Retry invalid rejected accepted States.ALL together with another error name invalid rejected accepted Catch Next names a missing state invalid rejected accepted custom error name starting with 'States.' unknown accepted accepted Fail state without Error valid accepted accepted 26 changes; 22 are invalid by the docs or the ASL spec as I read it. of those 22: rejected by Step Functions Local 21, by cfn-lint 12, by at least one 21, by neither 1 accepted by both although invalid: ['Retry IntervalSeconds 0'] 'valid' or 'unknown' cases rejected by a tool: [('Retry BackoffRate 0.5', 'rejected', 'accepted')]

Step Functions Local rejected 21 of the 22 invalid definitions; cfn-lint rejected 12. cfn-lint missed a Next pointing at no state, an unreachable state, both Wait mistakes, a Catch to a missing state, and every retry-field mistake the error-handling page defines (a jitter strategy of HALF, MaxDelaySeconds 0, States.ALL not last or not alone). Both accepted a Retry with IntervalSeconds 0 although the page says the value is "a positive integer"; I do not know what the real service does. AWS's own alternatives need an account: the TestState API (the Step Functions Local page recommends it instead of Step Functions Local) and the ValidateStateMachineDefinition API (it appears in the quotas page; I did not read how it behaves).

Lambda as a Queue Consumer

From the documentation (Lambda SQS pages):

  • Set the queue's visibility timeout "to at least six times the configuration timeout on your function"; with a batching window, six times the timeout plus MaximumBatchingWindowInSeconds. The function timeout must not exceed the queue's visibility timeout; Lambda checks this and returns an error.
  • Set maxReceiveCount to at least 5 on a queue read by Lambda, which gives Lambda a few chances to retry before a message goes to the dead-letter queue.
  • Batch size up to 10,000 on a standard queue and 10 on a FIFO queue; above 10 you must set a batching window of at least 1 second (standard queues only). A batch is also limited by the 6 MB synchronous payload.
  • By default, an error fails the whole batch and every message in it returns to the queue after the visibility timeout. With ReportBatchItemFailures the function returns {"batchItemFailures": [{"itemIdentifier": "id2"}]} and only those messages return. An empty or null list is a complete success; an invalid JSON response, an empty or null itemIdentifier, a wrong key name or an ID that does not exist is a complete failure; an exception fails the whole batch. For a FIFO queue the function "should stop processing messages after the first failure and return all failed and unprocessed messages".

Run here: a handler that reports only the records it cannot process, run in a real Lambda Python 3.13 container with sam local invoke and a five-record event made from the SAM-generated SQS fixture (which has a single record with the body Hello from SQS!; I copied the record five times and changed the IDs and bodies):

import json def handler(event, context): """Process each record on its own; report only the failures (ReportBatchItemFailures).""" failures = [] for record in event["Records"]: try: body = json.loads(record["body"]) if body.get("poison"): raise ValueError("cannot process " + body["order"]) print("processed", body["order"]) except Exception as exc: print("failed", record["messageId"], exc) failures.append({"itemIdentifier": record["messageId"]}) return {"batchItemFailures": failures}
sam local invoke BatchFn -e ev_five.json
No current session found, using default AWS::AccountId START RequestId: c2e777de-713f-48da-a03b-0fcb7ceaf46a Version: $LATEST processed A-1 failed id2 cannot process A-2 processed A-3 failed id4 cannot process A-4 processed A-5 END RequestId: c2e777de-713f-48da-a03b-0fcb7ceaf46a REPORT RequestId: c2e777de-713f-48da-a03b-0fcb7ceaf46a Init Duration: 0.04 ms Duration: 216.66 ms Billed Duration: 217 ms Memory Size: 128 MB Max Memory Used: 128 MB {"batchItemFailures": [{"itemIdentifier": "id2"}, {"itemIdentifier": "id4"}]}

The function returned exactly the documented shape for records 2 and 4. What this run does not show is what the Lambda service then does with the response and the queue: that is the event source mapping, which only exists on AWS, so the rules in the list above are from the documentation.

The Whole Design as a Template

Run here: one CloudFormation template for the chain (queue with a dead-letter queue, a FIFO queue, a topic with a filtered subscription and its queue policy, an event bus with a rule and an audit queue, the Lambda consumer and its mapping, and a state machine):

OrdersDLQ: Type: AWS::SQS::Queue Properties: QueueName: orders-dlq MessageRetentionPeriod: 1209600 OrdersQueue: Type: AWS::SQS::Queue Properties: QueueName: orders VisibilityTimeout: 185 MessageRetentionPeriod: 345600 ReceiveMessageWaitTimeSeconds: 20 RedrivePolicy: deadLetterTargetArn: !GetAtt OrdersDLQ.Arn maxReceiveCount: 5 PaymentsQueue: Type: AWS::SQS::Queue Properties: QueueName: payments.fifo FifoQueue: true ContentBasedDeduplication: true VisibilityTimeout: 60
OrdersSubscription: Type: AWS::SNS::Subscription Properties: TopicArn: !Ref OrdersTopic Protocol: sqs Endpoint: !GetAtt OrdersQueue.Arn RawMessageDelivery: true FilterPolicyScope: MessageAttributes FilterPolicy: store: [example_corp] price_usd: - numeric: [">=", 100]

It linted clean with cfn-lint 1.57.2 (exit status 0). My first draft of this template had a mistake that cfn-lint did not report: I set the queue's visibility timeout to 180 seconds with a 30-second function and a 5-second batching window, but the docs' rule is 6 × 30 + 5 = 185. I found it when I reread the Lambda page against the template, not through a tool, and then wrote the small checker below for the rules that need two resources together. I changed one thing at a time in the template (30 changes):

py mutate_stack.py # condensed
change cfn-lint 1.57.2 (control) template as written exit 0 queue VisibilityTimeout 185 -> 43201 caught E3034 queue MessageRetentionPeriod -> 59 caught E3034 queue MessageRetentionPeriod -> 1209601 caught E3034 ReceiveMessageWaitTimeSeconds 20 -> 21 caught E3034 queue MaximumMessageSize above 1 MiB caught E3034 queue DelaySeconds 901 caught E3034 FIFO queue name without .fifo caught E3501 ContentBasedDeduplication on a standard queue caught E3501 maxReceiveCount 5 -> 'five' caught E3012 DLQ target ARN is a queue that does not exist caught E1010 DLQ target ARN is the queue's own ARN caught E3004 event source mapping BatchSize 10 -> 10001 caught E3034 BatchSize 100 with no batching window missed FIFO queue mapped with BatchSize 11 caught E3705 function Timeout 30 -> 600 (queue timeout stays 185) missed queue VisibilityTimeout 185 -> 60 (less than 6 x 30 s) missed subscription Endpoint is the queue URL, not its ARN missed subscription RawMessageDelivery 'yes' missed FilterPolicyScope 'Body' missed filter policy operator 'numerc' missed queue policy removed (SNS may not send to the queue) missed audit queue policy trusts the wrong service missed event pattern with an unknown operator 'prefx' missed event pattern numeric with a string operand missed rule target RetryPolicy MaximumRetryAttempts 'many' caught E3012 Express workflow that has a callback (.waitForTaskToken) missed state machine definition is not valid JSON missed state machine Next names a state that does not exist missed Lambda runtime python3.7 (retired) caught E2531 consumer role trusted by the wrong service missed 30 one-change mutations: cfn-lint reported an error for 15; 15 passed or only warned

cfn-lint reported an error for 15 of 30 and passed or only warned for 15. It caught all the single-property ranges (the SQS limits above, a FIFO name without .fifo, batch sizes, a non-numeric retry count, a dead-letter queue that does not exist or is the queue itself). It missed everything that needs knowledge of two resources, or of what a string means: a function timeout longer than the queue's visibility timeout, a timeout shorter than six times the function's, a batch of 100 with no window, a subscription endpoint that is the queue URL instead of its ARN, RawMessageDelivery: yes, a filter-policy scope or operator that does not exist, a missing queue policy, a queue policy that trusts the wrong service, an event pattern with an operator that does not exist, a callback state in an Express workflow, and a state machine definition that is broken or points at a missing state.

Two of those misses I probed further, because the definition was inside Fn::Sub. With a plain DefinitionString cfn-lint also passed a definition that is not valid JSON, a Next to a missing state and an Express workflow with .waitForTaskToken; wrapped in Fn::Sub it printed only W1020 (a substitution rule). So the substitution is not the cause: this version does not check those things at all.

py (probe of DefinitionString plain and wrapped in Fn::Sub)
valid definition, plain string exit 0 - valid definition, wrapped in Fn::Sub exit 4 W1020 broken JSON (missing brace), plain string exit 0 - broken JSON (missing brace), wrapped in Fn::Sub exit 4 W1020 Next to a missing state, plain string exit 0 - Next to a missing state, wrapped in Fn::Sub exit 4 W1020 EXPRESS + .waitForTaskToken, plain string exit 0 - EXPRESS + .waitForTaskToken, wrapped in Fn::Sub exit 4 W1020 STANDARD + .waitForTaskToken, plain string (should be fine) exit 0 -

The checker I wrote reads the template, follows Ref and GetAtt between the mapping, the queue and the function, and applies the Lambda page's rules:

def check(text): t = yaml.load(text, Loader=Loader) R, P = t["Resources"], t.get("Parameters", {}) out = [] def val(v, default=None): n = ref_name(v) if n in P: return P[n].get("Default", default) return v if v is not None else default for mid, m in R.items(): if m["Type"] != "AWS::Lambda::EventSourceMapping": continue pr = m["Properties"] q, fn = R.get(ref_name(pr["EventSourceArn"])), R.get(ref_name(pr["FunctionName"])) if not q or q["Type"] != "AWS::SQS::Queue" or not fn: continue qp, fp = q["Properties"], fn["Properties"] vt = int(qp.get("VisibilityTimeout", 30)) timeout = int(val(fp.get("Timeout"), 3)) window = int(pr.get("MaximumBatchingWindowInSeconds", 0)) batch = int(pr.get("BatchSize", 10)) fifo = bool(qp.get("FifoQueue")) if timeout > vt: out.append(("ERROR", mid, f"function timeout {timeout} s is longer than the queue's visibility timeout {vt} s (Lambda rejects this mapping)")) need = 6 * timeout + window if vt < need: out.append(("WARN", mid, f"visibility timeout {vt} s is below the recommended 6 x {timeout} s + {window} s window = {need} s")) if fifo and batch > 10: out.append(("ERROR", mid, f"batch size {batch} on a FIFO queue (max 10)")) if not fifo and batch > 10000: out.append(("ERROR", mid, f"batch size {batch} on a standard queue (max 10,000)")) if batch > 10 and window < 1: out.append(("ERROR", mid, f"batch size {batch} > 10 needs MaximumBatchingWindowInSeconds of at least 1")) rp = qp.get("RedrivePolicy") if rp: mrc = int(rp.get("maxReceiveCount", 0)) if mrc < 5: out.append(("WARN", mid, f"maxReceiveCount {mrc} is below the recommended 5 for a Lambda-fed queue")) d = R.get(ref_name(rp.get("deadLetterTargetArn"))) if d and int(d["Properties"].get("MessageRetentionPeriod", 345600)) <= int(qp.get("MessageRetentionPeriod", 345600)): out.append(("WARN", mid, "the dead-letter queue's retention is not longer than the source queue's")) return out
py crosscheck_demo.py
the template as written no findings function Timeout 30 -> 600 (queue timeout stays 185) ERROR function timeout 600 s is longer than the queue's visibility timeout 185 s (Lambda rejects this mapping) WARN visibility timeout 185 s is below the recommended 6 x 600 s + 5 s window = 3605 s queue VisibilityTimeout 185 -> 60 WARN visibility timeout 60 s is below the recommended 6 x 30 s + 5 s window = 185 s queue VisibilityTimeout 180 (my first draft, with a 5 s window) WARN visibility timeout 180 s is below the recommended 6 x 30 s + 5 s window = 185 s BatchSize 100 with no batching window ERROR batch size 100 > 10 needs MaximumBatchingWindowInSeconds of at least 1 FIFO queue mapped with BatchSize 11 WARN visibility timeout 60 s is below the recommended 6 x 30 s + 5 s window = 185 s ERROR batch size 11 on a FIFO queue (max 10) maxReceiveCount 5 -> 3 WARN maxReceiveCount 3 is below the recommended 5 for a Lambda-fed queue DLQ retention 1209600 -> 345600 (same as source) WARN the dead-letter queue's retention is not longer than the source queue's

It flagged the three mutations cfn-lint missed in this group (function timeout above the queue's visibility timeout, a visibility timeout below six times the function's, a batch of 100 with no window) and my own 180, and I added three rules for good measure (FIFO batch size, maxReceiveCount below 5, a dead-letter queue that does not outlive its source). It is a sketch for this template's shape, not a general tool: a project that wants these checks should keep a script like it next to the template, with tests like the mutations above.

Against Real AWS (not run)

Level 2, needs an account. The boto3 calls used in this chapter are the same ones you would make against the real services: drop the endpoint_url, set credentials and a Region, and the code in sqs_behaviour.py, sns_tests.py, eb_pattern_tests.py and consumer_demo.py runs against AWS (queues and topics have names you must make unique, and the tests leave their resources behind, so a real run needs a clean-up step I did not write). There is no AWS CLI on this machine, so I give no CLI commands. The template deploys with aws cloudformation deploy or sam deploy (AWS Intermediate/Advanced 4 and 8), and costs money only for requests and state transitions at the rates above; set a budget alert first (AWS Fundamentals 9) and delete the stack afterwards.

Cost and Clean-Up

Nothing in this chapter created anything in AWS. On this machine: the scratch folder C:/lwaws/c9 (a Python environment and test files), the moto server process (stopped at the end) and the Step Functions Local container (removed at the end), and three Docker images: the Step Functions Local image (1.89 GB) and the two Lambda images from chapter 8 (795 MB and 834 MB). SAM's local invoke leaves no container behind (checked with docker ps -a).

What was and was not checked
Run here: 10 SQS behaviour checks; 12 SQS limit checks; SNS fan-out, raw delivery, two filter scopes and 12 operators; 43 EventBridge pattern cases against a reference matcher and moto (and a probe of the time field); 9 retry/catch scenarios and 26 definition mutations on Step Functions Local; 30 template mutations against cfn-lint; a cross-resource checker; a slow-consumer demonstration; a partial-batch handler in a real Lambda container; a cost sketch. From the documentation, not run: everything the real services do (limits, delivery, redrive, retention, throttling, prices, the event source mapping, retry behaviour on AWS). Not read or not run: SNS fan-out access policy, SNS FIFO topics, SQS and SNS price tables, EventBridge Scheduler, Pipes, archives and replay, Step Functions Express in practice, Map and Parallel behaviour, the TestState API, the ASL specification, moto's own Step Functions support, and a real account for any of it.

Practice

Exercise 1

Start the moto server and test SQS against the documentation: visibility timeout, ChangeMessageVisibility, a dead-letter queue, FIFO deduplication and group ordering, and long polling. Then test twelve documented limits, one value inside and one outside each, and report which ones the emulator enforces. Write an at-least-once consumer demonstration with a slow consumer and make the consumer idempotent.

📄 View solution
Exercise 2

Test SNS fan-out, raw delivery and filter policies (both scopes and twelve operators) against the docs' example message. Write a reference matcher for EventBridge event patterns from the documented operators, build 43 test cases from the docs' examples, and compare the matcher and the emulator. Find out why one emulator case disagrees.

📄 View solution
Exercise 3

Run Step Functions Local with a mock configuration and measure Retry and Catch: backoff, MaxDelaySeconds, jitter and the States.Timeout exception. Mutate a state machine definition 26 times against Step Functions Local and cfn-lint. Write a CloudFormation template for the whole chain, lint it, mutate it 30 times, and write a checker for the Lambda–SQS rules the linter cannot see. Run a partial-batch handler in a Lambda container.

📄 View solution

Chapter 9 Quick Reference

  • SQS: visibility timeout default 30 s (0 to 12 h); at-least-once, so make consumers idempotent; dead-letter queue after maxReceiveCount receives (set at least 5 for Lambda), with retention longer than the source queue's; long polling up to 20 s; FIFO needs MessageGroupId, deduplicates for 5 minutes, and holds back a group's later messages while one is in flight
  • SNS: no filter policy means every message; scopes MessageAttributes or MessageBody; raw delivery to SQS strips the envelope and allows 10 attributes; the queue must allow SNS to send (from my understanding, page not read)
  • EventBridge patterns: every pattern field must match, array values are alternatives, an event array matches on any shared element; prefix, suffix, anything-but, numeric, exists, cidr, wildcard, $or; null is not an empty string; default delivery retry is 24 h and 185 attempts, then dropped unless a dead-letter queue is set
  • Step Functions: Standard (up to a year, exactly once, 25,000 history events, billed per transition) versus Express (5 minutes, at least once, no .sync or callback); Retry defaults 1 s, 3 attempts, rate 2.0; States.ALL alone and last; a caught failure still ends as SUCCEEDED; retries count as transitions
  • Lambda from SQS: visibility timeout at least 6 × function timeout + batching window; batch above 10 needs a window of 1 s or more; use ReportBatchItemFailures and return itemIdentifiers; on FIFO stop at the first failure
  • Emulators: moto matched SQS and SNS behaviour but enforced only 6 of 12 SQS limits and failed one EventBridge time match; Step Functions Local is "unsupported" but timed retries exactly as documented. Use them for your code, not for limits or validity
  • cfn-lint caught 15 of 30 template mistakes: ranges and types yes, relations between resources and the meaning of strings no. Keep a cross-resource check and a deliberate-mistake test of your own
Coming next
Chapter 10 is edge and DNS: CloudFront, Route 53 and WAF. Much of it needs a real account, a real domain or both, so expect a lot of "from the documentation, not run" labels, and some local checks of cache-policy and DNS record logic. Nothing needs starting beforehand. Start with AWS Intermediate/Advanced 10.