There is a special kind of confidence that comes from an AI development environment that just works. You describe a feature, and it writes the Terraform, the Go, the Lambda handlers, the S3 wiring. It deploys. The happy path works. And you think: this is fine. I can go do something else now.
So one afternoon I handed Kiro a task, hit go, and left it running. Right on cue, I was handed a task of my own out in the backyard. So I left Kiro to it and went to deal with mine.
Hours later, my phone buzzed. It was AWS. They had detected a recursive loop between a Lambda function and an S3 bucket on my account, and they had helpfully disabled the function's invocations to stop the bleeding.
Let me back up.
The project
I've been building a small serverless recipe service on AWS: a private recipe bank for my family. You throw in a recipe as a photo, a chunk of text, or a URL, and the system extracts it, cleans it up, translates it to Finnish, structures it into ingredients and instructions, and stores it. All Terraform, all serverless, all Go on Lambda. Bedrock does the AI heavy lifting. Kiro does most of the typing.
It's the kind of project that's genuinely fun to build with an AI pair, because the work is mostly plumbing. DynamoDB single-table design, presigned S3 uploads, API Gateway routes, a Step Functions pipeline. None of it is hard. All of it is tedious. Exactly the sort of thing you're happy to delegate.
The feature
That afternoon's task wasn't about the recipe itself. It was about extra photos: the pictures you take while cooking, or of the finished bake sitting on the counter. The idea was that any family member could attach their own photos to a recipe, a shot of the dough half-mixed, the cake out of the oven, the plated result. A little photo gallery per recipe.
The one real bit of work there is sizing. A photo straight off a modern phone is several megabytes. You don't want to send that to a thumbnail grid, and you don't want the full original loading in a lightbox either. So for every uploaded photo the system needed to generate two derivatives:
- A 320px thumbnail for the gallery grid.
- A ~1024px "display" version for the click-to-enlarge view.
Here's the part worth being precise about: my instruction to Kiro was simply generate those two versions from each uploaded image. That's it. I said nothing about where the derivatives should be stored. I didn't ask for them to be written back next to the original, or into the same bucket, or anywhere specific. That was Kiro's own implementation choice, a perfectly reasonable-looking one, and it's exactly the choice that set the trap. More on that in a moment.
What Kiro built: a user photo gets uploaded to S3. An S3 ObjectCreated notification fires a Lambda. The Lambda reads the uploaded photo, resizes it into the thumbnail and display versions, and writes them back into the same bucket so they're ready to serve.
Kiro implemented it, wired the Terraform, and deployed it to my account. Then, to check the happy path, it uploaded a single test image to the service. And right about then, a few minutes later, my laptop slid into sleep mode, and Kiro went quiet with it. The change was live on AWS, one real image had just landed in the bucket, and the freshly deployed photo pipeline was now running entirely on its own.
The smell (that I didn't smell)
Here's the bug, and it's the kind that looks completely fine in your head.
The Lambda was triggered by "a new object in the bucket." It generated its derivative images and wrote them back into the same bucket. Those writes were new objects in the bucket. Which triggered the notification. Which fired the Lambda. Which generated derivatives and wrote them back into the bucket. Which triggered the notification.
Upload one photo, and you don't process one image. You process images forever.
upload photo -> S3 ObjectCreated -> Lambda
^ |
| v
writes -thumb.jpg, <- generates
-display.jpg into derivatives
the same bucket
Each pass wrote two new objects, and each of those two objects triggered its own invocation, which wrote two more. This isn't a slow leak. It's exponential. The only reason my account didn't melt is that AWS has a safety net for exactly this mistake: it detects recursive Lambda↔S3 invocation loops and cuts the function off. That's the email I woke up to.
To be clear, this is a well-known S3 footgun. It has been written about for years. "Don't trigger a Lambda from a bucket and write back to the same bucket without a guard" is practically a commandment. And yet here we were, not because I asked for it, but because the obvious implementation reads so innocently: generate a thumbnail, save it next to the original. Of course you save it next to the original. Where else would it go? That unspoken "of course" is the whole problem. I asked only for the versions, and the natural place to put them quietly closed the loop.
The fix
There are a few honest ways to break the loop:
- Separate buckets. Originals in one bucket, derivatives in another. The trigger bucket never receives the Lambda's own writes.
- Prefix filtering. Keep everything in one bucket but scope the S3 notification to a prefix the Lambda never writes to.
- Suffix filtering in the handler. Let the notification fire, but have the handler recognize its own output and bail out immediately.
I ended up using two of them at once, because after causing a runaway loop you stop trusting single points of failure.
First, a naming convention plus a scoped trigger. Uploaded photos are stored with an -orig suffix (<photoId>-orig.jpg); the derivatives get -thumb.jpg and -display.jpg. The S3 notification is filtered to fire only on the -orig suffix. So the Lambda's own output, which never ends in -orig, simply doesn't match the trigger. The feedback edge is cut at the event source.
Second, a defensive skip in the handler itself, in case some future me misconfigures that notification to be broader:
// Loop guard: only act on "-orig" originals. Anything else (our own
// -thumb/-display output, or unrelated keys) is ignored.
if !strings.HasSuffix(stem, "-orig") {
log.Printf("photothumbs skip non-orig key: %s", key)
return nil
}
So even if the trigger were somehow widened, the handler recognizes its own -thumb/-display output and bails immediately instead of resizing it and writing yet another object. On top of that, these user photos live under their own user-photos/ prefix, entirely separate from the originals/ prefix the recipe-extraction pipeline listens to, so the two never step on each other.
The whole fix is a few lines. The whole problem was a few lines. That's usually how it goes.
What I actually learned
1. "It deployed and the happy path worked" is not "it's safe to walk away." The demo worked perfectly. Add photo, see thumbnail. The bug only shows up in the shape of the system, the feedback edge from output back to input, which a happy path click never exercises. Recursive and runaway behavior hides in exactly the places a quick manual test doesn't reach.
2. A deploy-and-test step should watch the metrics, not just the result. Here's the question that's stuck with me. The loop ran for hours before AWS emailed me, so how could it have been caught in minutes, right after the test upload? Kiro did upload one test image to check the happy path. But "check the happy path" meant confirm a thumbnail appeared, so it looked at the result, not at the system's behavior. One glance at CloudWatch would have screamed: the photothumbs function's Invocations count climbing with no end, instead of the flat 1-per-upload you'd expect. That is the tell. For a bug whose entire symptom is "this runs more than it should," the signal lives in the metrics, not in the output.
So the real lesson is to make post-deploy verification include a short observability pass, not just a functional click:
-
Watch invocation and concurrency metrics for a beat after deploying. For an event-driven function, a quick look at
Invocations,ConcurrentExecutions, andDurationright after the first real event would have shown the runaway immediately. One upload producing hundreds of invocations is not subtle once you're actually looking. -
Set a guardrail alarm up front. A CloudWatch alarm on invocation count or concurrency (and
RecursiveInvocationsDropped, the metric Lambda emits when its own loop detector trips) turns "AWS emails you hours later" into "your phone pings you in two minutes." Deploying the function and the alarm together should be part of the same change. - Would X-Ray have helped? Yes, pointedly. Lambda's recursive-loop detection is built on X-Ray tracing headers; that's the mechanism by which AWS spotted the loop at all. With X-Ray tracing enabled on the function, the service map would have drawn the tell-tale cycle, Lambda writing to S3 and S3 invoking the same Lambda, as a literal loop in the diagram, and the trace timeline would show the invocations fanning out. For diagnosing "what is calling what, and how often," a trace map makes a feedback edge obvious in a way that staring at code does not.
None of this prevents the bug; the guards in "The fix" do that. But it collapses the time between "I shipped something wrong" and "I know I shipped something wrong" from hours to minutes, and that gap is where the cost (and the panic) lives.
3. The dangerous decisions are the ones you never explicitly made. I asked for two image sizes. I did not ask for them to be written back into the same bucket. That was an unprompted implementation detail, the kind that never shows up in your instruction and never gets reviewed because you don't even know a decision was made. And the code looked completely reasonable: generate a derivative, save it beside the original. A human would write the same thing. The failure is architectural, not syntactic, and neither an AI nor a compiler flags "this write re-enters the trigger you're inside of."
4. Know the footguns of the services you wire together. The S3→Lambda loop is a classic. So is the DynamoDB stream that writes back to its own table. When you connect an event source to a compute that can produce that same kind of event, always ask: can the output become the input? If yes, you need a guard before you deploy, not after AWS emails you.
5. Let the platform's safety nets be your last line, not your plan. AWS's recursive-loop detection genuinely saved me, and it exists precisely because this mistake is common. But it's a circuit breaker, not a design. Prefix scoping and a handler-side skip are the design.
6. The division of labor is real, and it's the whole point. Kiro wrote the volume: the Terraform, the handlers, the resize code, the pipeline. That's a lot of correct, tedious plumbing produced fast. What it can't do is hold the judgment, the "wait, this Lambda writes into the bucket that triggers it." That part is still mine. The combination is fast and safe only when I show up for my half.
Was it a disaster?
Honestly, no. No data was lost. The cost of a few thousand pointless image conversions before AWS pulled the plug was trivial, cents rather than dollars. The loop detector did its job. I re-enabled the function, deployed the guard, and the feature has worked correctly ever since.
But it's a good reminder of where we actually are with AI-assisted development. The speed is real. A weekend feature is now an afternoon feature. And the responsibility is also real, and it hasn't moved. Kiro will get you to a deploy astonishingly fast. Whether that deploy is a good idea is still a call you have to make, ideally before you get called out to the backyard.
So: build fast, delegate the plumbing, enjoy the leverage. Just don't confuse "the AI deployed it" with "someone checked whether the output feeds the input."
Don't try that one at home.

Top comments (0)