Serverless orchestration
The automation payoff is things happening without you. Lambda runs the code, EventBridge routes events, and Step Functions sequences multi-step AI pipelines with retries.
- AWS credentials (
aws configure) +pip install boto3
Learning objectives
- Trigger AI processing from an S3 upload with Lambda + EventBridge.
- Orchestrate a multi-step AI pipeline with Step Functions.
- Handle the async services (Textract/Transcribe) without polling by hand.
- Reason about retries, error paths, and idempotency in AI automation.
code/aws13-serverless-orchestration/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.Event-driven AI advanced
The automation payoff is things happen without you. A document lands in S3 → a pipeline extracts, classifies, and stores it, with no human trigger. Lambda runs the code, EventBridge routes the events, and Step Functions sequences multi-step flows with built-in retries.
Picture the version you're trying to escape. You wrote an AI pipeline that works beautifully — you run it by hand, it downloads the file, calls Textract, calls Claude, writes the row. So you put it on a cron job that fires every five minutes, wakes up, lists the bucket, and asks "anything new?" Most of the time the answer is "no," and you've paid for a server to sit there asking a question with no answer. Worse: when 500 invoices arrive at 9am on the 1st of the month, your one poor script processes them one at a time while the queue backs up, and if the box crashes halfway through you have no idea which files it already handled. That whole shape — a machine you keep running, polling for work it usually doesn't have, that doesn't scale and doesn't remember — is exactly what event-driven serverless deletes.
The mental shift is from pull to push. Instead of your code repeatedly asking "did anything happen?", AWS tells your code the instant something does. The upload itself becomes the trigger. Nothing runs — and nothing is billed — until a real event arrives, and then as many copies as needed run at once. That's the deep meaning of "serverless": not that there are no servers, but that you never provision, scale, or babysit one. You hand AWS a function and a rule for when to run it, and it handles the rest.
Three services do the heavy lifting, and it helps to hold each one's job clearly in your head before the labs:
- Lambda is the worker — a single function that runs on demand, scales to zero when idle, and spins up a fresh copy per event. It's where your actual code lives.
- EventBridge (and native triggers like S3 notifications) is the wiring — the rules that say "when this happens, run that." It decouples the thing that produces an event from the thing that reacts to it, so you can add or swap reactions without touching the source.
- Step Functions is the conductor — it sequences several Lambdas into one durable, multi-step workflow, remembering where each run is and retrying failures for you. This is what keeps a pipeline reliable across steps that each might fail.
The common beginner mistake here is reaching for these too early — wiring five Lambdas and a state machine for a job a single script could do. Event-driven design earns its complexity when work is bursty, multi-step, and must not be lost; for a steady trickle of simple tasks, a plain queue or scheduled job is often the honest answer. The rest of this chapter shows the shape you graduate to, and when it's worth it.
The dashed line down the middle splits the same job into two designs — the wasteful "pull" world on the left, the serverless "push" world on the right.
- On the left (red), a cron server is always on. The two dashed arrows are its loop: it repeatedly asks the S3 bucket "anything new?" and usually hears "no" — you pay for that idling, it handles files one at a time, and it forgets its place if it crashes.
- On the right, the blue upload box is the trigger: the arrival of a file pushes an event (solid blue arrows) into EventBridge (the wiring), which invokes Lambda (the worker), while Step Functions (the conductor) sequences the multi-step flow.
- The payoff line under the right side is the "aha": nothing runs — and nothing is billed — until an event arrives, and then as many Lambda copies as needed run at once.
In short: polling keeps a server busy asking a question with no answer; event-driven serverless only wakes when a real event pushes work to it.
S3 → Lambda trigger advanced
This is the single most common serverless pattern in AWS, and it's worth understanding down to the mechanics. The story is always the same: a file appears in a bucket, and you want something to happen — extract its text, resize the image, index it, kick off a pipeline. The question is how your code learns the file arrived. The naive answer is to poll the bucket on a timer, which we just saw is wasteful and slow. The serverless answer is a bucket notification: you configure S3 once with a rule — "when an object is created here, invoke this Lambda" — and from then on the upload itself calls your function, within moments, automatically.
The piece that trips people up is what the Lambda actually receives. AWS doesn't hand your function the file — it hands it a small JSON event describing what happened. For an S3 trigger that event carries the bucket name and the object key (the file's path within the bucket), buried in a fixed nested shape: event["Records"][0]["s3"]. There can be more than one record, which is why it's a list. Your first job in almost every S3-triggered Lambda is to reach into that structure and pull out the bucket and key — and then, if you need the bytes, call S3 yourself to fetch them. Learn that event shape cold; you will type it hundreds of times.
One subtlety worth flagging before the lab, because it's a real production gotcha: object keys arrive URL-encoded in the event. A file literally named Q3 report.pdf shows up as Q3+report.pdf or Q3%20report.pdf. If you pass that raw string back to S3 or Textract, the service looks for a file with a plus sign or percent-twenty in its name and reports "not found" — a bug that only surfaces once a real user uploads a file with a space or a non-ASCII character. That's why the lab runs the key through urllib.parse.unquote_plus before using it.
The other idea the lab quietly teaches is don't wait when you don't have to. Reading a large multi-page document is slow, and a Lambda that blocks while Textract grinds is a Lambda you're paying for by the millisecond to do nothing. So instead of waiting, this handler starts the analysis and returns immediately, letting AWS notify a second function when the result is ready. Fire-and-hand-off, not sit-and-wait — that instinct is what separates a serverless design that scales cheaply from one that quietly runs up a bill.
jobId straight away; Textract later publishes to an SNS topic, which triggers a separate Lambda B — so no function is ever paid to block while OCR grinds.
Follow the solid blue arrows left to right along the top row, then drop down to the second row where the result comes back later.
- The top row is the trigger chain: an S3 upload fires an event that invokes Lambda A (green = your worker code), which pulls the bucket and key out of the event and calls Textract to start an async analysis.
- The dashed grey arrow down to the amber returns jobId box is the key move: Lambda A does not wait — it returns the tracking id immediately (marked "no waiting") and stops being billed.
- The bottom-right chain is what happens later: when Textract finishes it publishes to the purple SNS topic ("job done"), which triggers a second function, Lambda B, to handle the extracted result — event-driven, with no polling loop.
In short: one function kicks off the slow work and leaves; a notification wakes a different function to finish the job when the result is actually ready.
s3_trigger.py# Lambda triggered by an S3 put. It kicks off async Textract and returns;
# a second trigger (Textract -> SNS -> Lambda) handles the result.
import boto3, urllib.parse
tx = boto3.client("textract")
def lambda_handler(event, context):
rec = event["Records"][0]["s3"]
bucket = rec["bucket"]["name"]
key = urllib.parse.unquote_plus(rec["object"]["key"])
job = tx.start_document_analysis(
DocumentLocation={"S3Object": {"Bucket": bucket, "Name": key}},
FeatureTypes=["FORMS", "TABLES"],
NotificationChannel={
"SNSTopicArn": "arn:aws:sns:us-east-1:123456789012:textract-done",
"RoleArn": "arn:aws:iam::123456789012:role/textract-sns",
},
)
return {"jobId": job["JobId"]}
This is a Lambda function — a small piece of code AWS runs for you, automatically, with no server to manage. It runs the instant a file is uploaded to an S3 bucket (S3 is AWS's file storage). Its job here is to start reading the document, then get out of the way: document analysis can take a while, so it kicks off the work and returns immediately instead of waiting.
tx = boto3.client("textract")—boto3is the AWS toolkit for Python. This line opens a connection to Textract, the AWS service that reads text, forms, and tables out of documents (PDFs, scans).def lambda_handler(event, context):is the entry point AWS calls when the function fires. AWS hands it anevent— a bundle of data describing what happened. For an S3 upload, that event contains which bucket and which file.- The next three lines dig the details out of the event: the
bucketname and the filekey(its path/name).unquote_plusundoes URL-encoding so a name likemy%20file.pdfbecomesmy file.pdf. tx.start_document_analysis(...)tells Textract "start reading this file."FeatureTypes=["FORMS", "TABLES"]asks it to also pull out form fields and tables, not just plain text.- The
NotificationChannelblock is the clever part: instead of waiting, we tell Textract "when you're done, post a message to this SNS topic" (SNS is AWS's notification/messaging service). A separate Lambda listens on that topic and handles the finished result later.
What the output means: The function returns {"jobId": job["JobId"]} — just a tracking number for the Textract job. It does not return the extracted text; that arrives later via the SNS notification. Returning fast like this is normal and correct for event-driven code.
Try this: Trace what happens if two files are uploaded at once: AWS simply runs two copies of this Lambda in parallel, each with its own event. That automatic scaling is the whole point of "serverless."
while True: sleep() loop. This is the production shape.Step Functions: sequence the steps expert
A Step Functions state machine is your pipeline as JSON: extract → classify with Claude → store, with automatic retries and a catch path per state. It is the durable orchestration layer you would otherwise hand-code.
Here's the pain this section removes. One Lambda triggered by one upload is easy. But real AI pipelines are chains: extract the text, then classify it with Claude, then write the result to a database — three steps where each depends on the last, and each can fail on its own. The tempting first draft is to cram all three into one giant Lambda with nested try/except blocks, a manual retry loop, and a variable tracking "which step am I on." That code is fragile: if the Lambda times out at step two, you've lost all state and have no clean way to resume. And you've hand-rolled retry logic that you'll get subtly wrong. Chaining steps reliably, across failures, is genuinely hard — and it's exactly the problem AWS packaged up as Step Functions.
The key mental move is that a Step Functions workflow is data, not code. You don't write a program that calls the steps; you write a JSON document — in a dialect called Amazon States Language (ASL) — that describes the steps and the arrows between them, and AWS executes that description for you. Think of it as a flowchart you can version-control. Each box in the flowchart is a state: most are Task states that run one Lambda, and each state names its successor with Next (or declares itself the finish with End: true). AWS runs the states in order, remembers exactly where each execution is, and shows you a live diagram lighting up as the run progresses.
The reason this earns its own service — rather than being a fancy loop — is two per-state safety features that would otherwise be scattered, error-prone code:
Retryre-runs a failing state automatically, up toMaxAttempts, with back-off between tries. Transient hiccups (a throttled API, a blip) heal themselves with zero code from you. This is for errors you expect to pass on a second try.Catchis the escape hatch for errors that won't heal: instead of crashing the whole pipeline, it routes the failed run to a different state — typically a "dead-letter" step that parks the document safely for a human to inspect. This is for errors you expect to stay broken.
The distinction between those two is the thing to internalize: Retry says "try the same thing again," Catch says "give up on this path and go somewhere else." Get them backwards — catching what you should retry — and a momentary blip permanently dead-letters a perfectly good document. Reading them in the JSON below, notice that failure handling lives in configuration, declaratively, right next to the step it protects — not buried in imperative try/except code three functions deep.
Next happy path Extract → Classify → Store. The curved arrow over Extract is a Retry (same step again); the arrow down from Classify is a Catch sending any error to a DeadLetter state instead of crashing the run.
Read the three top boxes left to right — that's the pipeline's happy path — then notice the two failure arrows that leave it.
- The solid blue arrows labelled
Nextare the normal flow: Extract → Classify → Store. The green Store box ends the run (End: true). - The curved dashed arrow looping back onto Extract is
Retry ×3: a transient failure re-runs the same state up toMaxAttempts— reliability with no code from you. - The dashed arrow dropping down from Classify to the red DeadLetter box is
Catch: on any error it abandons the happy path and parks the document safely for a human, instead of crashing the whole pipeline.
In short: Retry curls back to try the same step again; Catch branches off to a safe dead-end — and both live in configuration right beside the step they protect.
pipeline.asl.json{
"Comment": "Doc intelligence pipeline",
"StartAt": "Extract",
"States": {
"Extract": { "Type": "Task", "Resource": "arn:aws:lambda:...:extract",
"Retry": [{"ErrorEquals": ["States.ALL"], "MaxAttempts": 3}],
"Next": "Classify" },
"Classify": { "Type": "Task", "Resource": "arn:aws:lambda:...:classify-with-claude",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "DeadLetter"}],
"Next": "Store" },
"Store": { "Type": "Task", "Resource": "arn:aws:lambda:...:store", "End": true },
"DeadLetter": { "Type": "Task", "Resource": "arn:aws:lambda:...:dlq", "End": true }
}
}
This is not program code — it's a configuration file (JSON) that describes a Step Functions state machine. Think of it as a flowchart written as text: AWS reads it and runs your steps in order, one after another, keeping track of where it is. Each step is a "state." This one wires up a document pipeline: Extract → Classify → Store, with a safety exit if something breaks.
"StartAt": "Extract"names the first step."States"then lists every step by name.- Each step has
"Type": "Task"(do a unit of work) and a"Resource"— the ARN (AWS's unique ID) of the Lambda function that does that step's work. SoExtractruns one Lambda,Classifyruns another (classify-with-claude), and so on. "Next": "Classify"is the arrow between steps: after Extract finishes, go to Classify. The last real step uses"End": trueto stop."Retry"on Extract means: if it fails, automatically try again up to"MaxAttempts": 3times before giving up. You get this reliability for free — no retry code to write."Catch"on Classify is the safety net:"ErrorEquals": ["States.ALL"]means "on any error, jump toDeadLetter" instead of crashing the whole pipeline.DeadLetteris a step that parks failed documents somewhere safe for a human to look at.
What the output means: Nothing prints — this file is uploaded to AWS as the definition of the pipeline. When you later start an execution, AWS walks these states in order and shows you a live diagram of which step is running, succeeded, retried, or failed.
Try this: Follow the two possible paths with your finger: the happy path is Extract → Classify → Store → End; the failure path is Classify → (error) → DeadLetter. Every state either points to a "Next" or says "End": true.
Invoke and observe expert
You've described a pipeline; now you need to run one and watch it. In steady-state production the pipeline usually starts on its own — an upload event kicks it off — but you'll constantly want to fire a run by hand: to test a change, to reprocess a specific document, or to trigger the flow from another program. Starting a Step Functions workflow is a single API call, start_execution, and understanding the two things it takes and the one thing it returns is the whole of this section.
The distinction to hold onto is state machine vs. execution. The state machine is the blueprint — the ASL definition you wrote once, identified by its stateMachineArn. An execution is one live run of that blueprint against one specific input. You can have hundreds of executions of the same state machine running at once, each processing a different document, each tracked independently. It's the difference between a recipe and the individual dinners you cook from it — one recipe, many meals, each with its own outcome.
Two details in the call matter. First, the input is a JSON string, not a Python dictionary — that's why you wrap your payload in json.dumps. Forgetting this is the classic first-run error: pass a raw dict and the SDK rejects it. Second, the call hands back an executionArn — a unique receipt for this run. That ARN is how you find the run later: paste it into the Step Functions console and you get a live graph of your states lighting up green as they succeed, amber as they retry, red if they fail into the Catch path. That visual audit trail — being able to see exactly where any given document got to, and why it failed — is a large part of why teams reach for Step Functions instead of stitching Lambdas together by hand. Observability isn't bolted on; it comes free with the orchestration.
start_execution spawns an independent run against one input, tracked by its own executionArn — so hundreds of documents can be in flight at once.
Read it top to bottom: the single blueprint at the top, the API call in the middle, and the many separate runs it produces along the bottom.
- The purple box at the top is the state machine — the ASL definition you wrote once, identified by its
stateMachineArn. It is a blueprint, not a running thing. - The blue middle box is the call
start_execution(input=json.dumps(…)); the dashed arrow shows it acting on the blueprint. Each call launches one run. - The three green boxes are executions — live runs of the same blueprint against different inputs (invoice, report, scan), each with its own
executionArn. The fan-out of blue arrows is the payoff: many independent, separately tracked runs from one definition.
In short: the state machine is the recipe; every start_execution cooks one meal from it, and each meal has its own receipt (the execution ARN) you use to track it.
start_sfn.pyimport boto3, json
sfn = boto3.client("stepfunctions", region_name="us-east-1")
run = sfn.start_execution(
stateMachineArn="arn:aws:states:us-east-1:123456789012:stateMachine:doc-intel",
input=json.dumps({"bucket": "my-docs", "key": "invoice.pdf"}),
)
print("execution:", run["executionArn"])
This tiny script manually starts the pipeline you defined in Lab W13.2. In production the pipeline usually starts on its own (an upload event), but this is how you'd trigger one run yourself — for testing, or from another program.
sfn = boto3.client("stepfunctions", region_name="us-east-1")opens a connection to the Step Functions service in a specific AWS region.sfn.start_execution(...)says "run one instance of this pipeline now."stateMachineArnis the ID of the pipeline (the state machine from Lab W13.2).input=json.dumps({...})is the starting data handed to the first step.json.dumpsturns the Python dictionary into a JSON text string, because that's the format Step Functions expects. Here we tell it which document to process: bucketmy-docs, fileinvoice.pdf.print("execution:", run["executionArn"])shows the ID of this specific run so you can find it in the AWS console.
What the output means: You get one line like execution: arn:aws:states:...:execution:doc-intel:abc123. That ARN is a receipt for this run — paste it into the Step Functions console to watch the Extract → Classify → Store steps light up in real time.
Try this: Change the "key" to a different filename and start it again — each call creates a brand-new, independently tracked execution with its own ARN.
Exercise W13.1 — Wire the pipeline
Context: A pipeline is only real once it survives the console: an upload triggers it end to end, and a forced failure proves the error path actually catches. This exercise wires and then breaks the flow on purpose.
Your task: Connect S3 upload → Lambda → Step Functions (extract → Claude classify → DynamoDB store). Upload a document, watch the execution in the Step Functions console, then force an error in the classify step and confirm the catch path sends it to the dead-letter state.
Requirements:
- An S3 upload triggers a Lambda that starts the Step Functions execution
- The state machine runs extract → Claude classify → DynamoDB store in order
- Watch a successful execution graph in the Step Functions console
- Deliberately force a failure in the classify state
- Confirm the
Catchroutes the failed run to the dead-letter state - Verify the DynamoDB write only happens on the success path, not the failed one
💡 Hint: Prove both paths: one clean run that reaches the store state, and one forced-error run whose Catch diverts it to dead-letter before any write.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Event-driven pipelines usually begin when a file lands in S3. The very first thing your Lambda must do is read the bucket and key out of the event S3 delivers — the shape of that event is fixed and worth knowing cold.
Your task: Write a Lambda handler that reads the bucket name and object key from an S3 event record. Runs offline as a pure handler.
Requirements:
- Accept the standard
(event, context)handler signature - S3 delivers records under
event["Records"]; read the first record'ss3block - Pull the bucket from
s3.bucket.nameand the key froms3.object.key - Return (or print) the extracted bucket and key
- Testable offline by passing a hand-built S3-shaped event dict
💡 Hint: The event nesting is Records[0].s3.bucket.name and Records[0].s3.object.key; hard-code a sample event to test without AWS.
Show solution
S3 delivers events under event["Records"][0]["s3"]; pull bucket and object key from there.
def lambda_handler(event, context):
rec = event["Records"][0]["s3"]
bucket = rec["bucket"]["name"]
key = rec["object"]["key"]
print("new object:", bucket, key)
return {"bucket": bucket, "key": key}
evt = {"Records": [{"s3": {"bucket": {"name": "docs"},
"object": {"key": "in/a.pdf"}}}]}
print(lambda_handler(evt, None)) # {'bucket':'docs','key':'in/a.pdf'}
Context: Multi-page documents can't be OCR'd in one synchronous call, so Textract offers an async job that notifies you on completion via SNS — no hand-rolled polling loop, which is the serverless-friendly way to wait.
Your task: Start Textract with start_document_analysis and a NotificationChannel so SNS fires when the job completes.
Requirements:
- Call
start_document_analysison aboto3textractclient withDocumentLocationpointing at the S3 object - Request the
FeatureTypesyou need (e.g. FORMS, TABLES) - Supply a
NotificationChannelwith anSNSTopicArnand theRoleArnTextract assumes to publish - Capture the returned
JobIdfor correlating the later completion notification - Understand this replaces polling: SNS signals completion instead
💡 Hint: The async start returns only a JobId; the transcript of work arrives later, and the NotificationChannel is what pushes you the "done" signal.
Show solution
For multi-page docs, kick off the async job and let SNS notify you instead of hand-rolled polling.
import boto3
tx = boto3.client("textract", region_name="us-east-1")
resp = tx.start_document_analysis(
DocumentLocation={"S3Object": {"Bucket": "docs", "Name": "in/a.pdf"}},
FeatureTypes=["FORMS", "TABLES"],
NotificationChannel={"SNSTopicArn": "arn:aws:sns:us-east-1:123:tx-done",
"RoleArn": "arn:aws:iam::123:role/textract-sns"},
)
print(resp["JobId"])
Context: Step Functions lets you chain Lambdas into a resilient workflow declared as data (ASL JSON), with retries and error catches built in — so failure handling lives in configuration, not scattered try/except code.
Your task: Write the Amazon States Language JSON for a linear flow Extract → Classify → Store, with a retry and a dead-letter catch on the first state. Validate it offline.
Requirements:
- Set
StartAtand define each stage as aTaskstate underStates - Chain the stages with
Next, and mark the terminal state withEnd: true - On the Extract state, add a
Retryblock (e.g.States.ALL,MaxAttempts) - On the Extract state, add a
Catchthat routes failures to a dead-letterFailstate - Confirm it is valid JSON offline (e.g. serialize it) — no execution needed
💡 Hint: ASL is just a dict you can build in Python and json.dumps; Retry and Catch are per-state lists that give resilience with zero custom code.
Show solution
ASL chains Task states with Next/End; Retry and Catch give resilience without custom code.
import json
asl = {
"StartAt": "Extract",
"States": {
"Extract": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123:function:extract",
"Retry": [{"ErrorEquals": ["States.ALL"], "MaxAttempts": 3}],
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "DeadLetter"}],
"Next": "Classify"},
"Classify": {"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123:function:classify",
"Next": "Store"},
"Store": {"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123:function:store",
"End": True},
"DeadLetter": {"Type": "Fail", "Error": "ExtractFailed"},
},
}
print(json.dumps(asl)[:60], "...") # valid JSON
Context: Once a state machine exists, you kick off a run with a single API call and a JSON input. That call returns an execution ARN you use to track and reference the specific run.
Your task: Use Step Functions start_execution to launch the state machine with a JSON input.
Requirements:
- Call
start_executionon aboto3stepfunctionsclient - Pass the
stateMachineArnof the workflow to run - Serialize the run input to a JSON string for the
inputargument - Read the returned
executionArnthat identifies this run
💡 Hint: The input is a JSON string, not a dict — json.dumps your payload; the response's executionArn handles this specific execution.
Show solution
start_execution takes the state-machine ARN and a JSON-string input and returns an executionArn.
import boto3, json
sfn = boto3.client("stepfunctions", region_name="us-east-1")
resp = sfn.start_execution(
stateMachineArn="arn:aws:states:us-east-1:123:stateMachine:doc-pipeline",
input=json.dumps({"bucket": "docs", "key": "in/a.pdf"}),
)
print(resp["executionArn"])
Context: Serverless triggers deliver at-least-once, so the same event can fire twice. Any step with side effects must be idempotent — keying writes by document id makes a re-delivery a harmless no-op instead of a double-post.
Your task: Implement process_once(doc_id, store) so re-processing the same document id is a no-op. Runs offline.
Requirements:
- Check whether
doc_idis already recorded in the store before doing work - On a first sighting, record the id and return a
processedresult - On a repeat, return a
skipped_duplicateresult and do not write again - The store is keyed by id (a dict/set stands in for DynamoDB) so the guard is O(1)
- Demonstrate the second call on the same id is skipped, offline
💡 Hint: The dedupe key is the document id; the presence check must happen before the write so a redelivery never repeats the effect.
Show solution
At-least-once delivery means duplicates; keying by id makes reprocessing safe.
def process_once(doc_id, store):
if doc_id in store:
return {"status": "skipped_duplicate", "id": doc_id}
store[doc_id] = {"processed": True} # idempotent write keyed by id
return {"status": "processed", "id": doc_id}
seen = {}
print(process_once("doc-1", seen)) # processed
print(process_once("doc-1", seen)) # skipped_duplicate
Context: Invoices land in S3 at unpredictable volume, and the pipeline must survive transient failures without ever posting an invoice twice. That means composing triggers, Step Functions retry/catch, and an idempotency guard into one hands-off flow.
Your task: Design the event-driven pipeline (S3 → Lambda → async Textract → SNS → Step Functions) and encode the failure policy: retry transient errors 3×, dead-letter after that, and never double-post. Model the decision offline.
Requirements:
- The decision takes a document id, attempt number, and error state (plus the dedupe store)
- Guard idempotency first: a known
doc_idyieldsskip_duplicate - A transient error under the attempt cap yields
retry(Step FunctionsRetry) - An error at/after the cap yields
dead_letter(theCatchto a DLQ) - A clean run records the id and returns
posted - Runs offline — it models the retry/idempotency decision, it doesn't call AWS
💡 Hint: Order the checks so the idempotency guard wins over retry, and retry wins over dead-letter; the attempt count versus a max is what separates "try again" from "give up".
Show solution
Compose triggers + Step Functions retry/catch + idempotency for a hands-off, resilient pipeline.
def handle(doc_id, attempt, error, store, max_attempts=3):
if doc_id in store:
return "skip_duplicate" # idempotency guard
if error and attempt < max_attempts:
return "retry" # transient -> Step Functions Retry
if error:
return "dead_letter" # exhausted -> Catch to DLQ
store[doc_id] = True
return "posted"
store = {}
print(handle("A1", 1, "Throttling", store)) # retry
print(handle("A1", 3, "Throttling", store)) # dead_letter
print(handle("A1", 1, None, store)) # posted
print(handle("A1", 1, None, store)) # skip_duplicate
✓ Checkpoint — you can move on when you can…
- Trigger a Lambda from an S3 upload.
- Handle async Textract via SNS instead of polling.
- Express a multi-step pipeline as a Step Functions state machine with retries + catch.
- Explain why idempotency matters in event-driven AI.
Knowledge check check yourself
In the S3 -> Lambda -> Textract flow, why does the Lambda start_document_analysis and return immediately instead of waiting, and how is the result handled?
Show answer
In the Step Functions state machine, what is the difference between a state's Retry and its Catch, and why is idempotency stressed for event-driven pipelines?