I tried out Lambda's recursive loop detection now that it supports the new EventBridge Custom Event Bus
This page has been translated by machine translation. View original
Introduction
On October 14, 2026, Lambda's recursive loop detection added support for EventBridge custom event buses.
In configurations where a Lambda function that receives an event publishes an event to the same event bus, a misconfiguration or code error can cause the function to be invoked indefinitely. Since EventBridge was previously excluded from detection, charges could continue to accumulate until the issue was noticed, and there was a risk of exhausting the account's concurrent execution limit.
With this update, such loops will be automatically stopped and notifications will be delivered.
In this article, we will actually create a Lambda function loop with a Custom Event Bus and verify whether it truly stops!
Please note that existing configurations built with rules and targets are not covered. The verification was conducted in the Tokyo region.
Target is the New Custom Event Bus
EventBridge currently has two types of custom event buses[1].
- Custom Event Bus: A new event bus where subscribers filter events and deliver them to targets. The CloudFormation resource type is
AWS::EventsV2::EventBus - Custom Event Bus - Classic: A traditional event bus configured with rules and targets. The CloudFormation resource type is
AWS::Events::EventBus
Hereafter, the former will be referred to as "Custom Event Bus" and the latter as "Classic."
Incidentally, the targets for Lambda's recursive loop detection are SQS, S3, SNS, Custom Event Bus, and Lambda-to-Lambda invocations[2].
For details on how it works and its background, please refer to the following article.
Verification Environment
We created a loop where a Custom Event Bus subscriber invokes a Lambda function, and that function publishes an event to the same bus via PutEvents.

The subscriber's InvocationType can be either asynchronous EVENT or synchronous REQUEST_RESPONSE. We prepared both and separated them into distinct loops using a detail-type filter.
The Lambda function code is as follows. The hop count is carried in the event's detail, and the function stops itself after 30 hops.
import json, os
import boto3
events = boto3.client("eventbridgev2")
def handler(event, context):
hop = event["detail"]["hop"]
print(json.dumps({
"hop": hop,
"trace": os.environ.get("_X_AMZN_TRACE_ID"),
"boto3": boto3.__version__,
}))
# Safety measure in case detection fails
if hop >= int(os.environ["MAX_HOP"]):
return
events.put_events(
EventBusArn=os.environ["BUS_ARN"],
Entries=[{
"Source": "blog.recursion",
"DetailType": os.environ["DETAIL_TYPE"],
"Detail": json.dumps({"hop": hop + 1}),
}],
)
The Custom Event Bus API uses a separate client called eventbridgev2 in boto3.
The boto3 v1.42.97 bundled with the Python 3.13 runtime did not include this client. Since eventbridgev2 was added in botocore v1.43.102[3], boto3 v1.43.118 is included as a Lambda layer.
When published via PutEvents, the Lambda function receives the envelope in the same format as Classic. Setting the subscriber's MaxBatchSize to 1 delivers a single object rather than an array[4].
CloudFormation Template
AWSTemplateFormatVersion: "2010-09-09"
Description: Lambda recursive loop detection test with EventBridge Custom Event Bus
Parameters:
Boto3LayerArn:
Type: String
Resources:
Bus:
Type: AWS::EventsV2::EventBus
Properties:
Name: recursion-test-v2
FailureQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: recursion-test-v2-failure
FunctionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: put-events
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: events:PutEvents
Resource: !Ref Bus
- Effect: Allow
Action: sqs:SendMessage
Resource: !GetAtt FailureQueue.Arn
# Role for the subscriber to invoke Lambda
DeliveryRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: events.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: deliver
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource:
- !GetAtt AsyncFunction.Arn
- !GetAtt SyncFunction.Arn
- Effect: Allow
Action: sqs:SendMessage
Resource: !GetAtt FailureQueue.Arn
AsyncFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: recursion-test-v2-async
Runtime: python3.13
Handler: index.handler
Role: !GetAtt FunctionRole.Arn
Layers:
- !Ref Boto3LayerArn
Environment:
Variables:
BUS_ARN: !Ref Bus
DETAIL_TYPE: async
MAX_HOP: "30"
Code:
ZipFile: |
import json, os
import boto3
events = boto3.client("eventbridgev2")
def handler(event, context):
hop = event["detail"]["hop"]
print(json.dumps({
"hop": hop,
"trace": os.environ.get("_X_AMZN_TRACE_ID"),
"boto3": boto3.__version__,
}))
# Safety measure in case detection fails
if hop >= int(os.environ["MAX_HOP"]):
return
events.put_events(
EventBusArn=os.environ["BUS_ARN"],
Entries=[{
"Source": "blog.recursion",
"DetailType": os.environ["DETAIL_TYPE"],
"Detail": json.dumps({"hop": hop + 1}),
}],
)
SyncFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: recursion-test-v2-sync
Runtime: python3.13
Handler: index.handler
Role: !GetAtt FunctionRole.Arn
Layers:
- !Ref Boto3LayerArn
Environment:
Variables:
BUS_ARN: !Ref Bus
DETAIL_TYPE: sync
MAX_HOP: "30"
Code:
ZipFile: |
import json, os
import boto3
events = boto3.client("eventbridgev2")
def handler(event, context):
hop = event["detail"]["hop"]
print(json.dumps({
"hop": hop,
"trace": os.environ.get("_X_AMZN_TRACE_ID"),
"boto3": boto3.__version__,
}))
# Safety measure in case detection fails
if hop >= int(os.environ["MAX_HOP"]):
return
events.put_events(
EventBusArn=os.environ["BUS_ARN"],
Entries=[{
"Source": "blog.recursion",
"DetailType": os.environ["DETAIL_TYPE"],
"Detail": json.dumps({"hop": hop + 1}),
}],
)
AsyncInvokeConfig:
Type: AWS::Lambda::EventInvokeConfig
Properties:
FunctionName: !Ref AsyncFunction
Qualifier: $LATEST
MaximumRetryAttempts: 0
DestinationConfig:
OnFailure:
Destination: !GetAtt FailureQueue.Arn
AsyncSubscriber:
Type: AWS::EventsV2::Subscriber
Properties:
Name: recursion-test-async
EventBusArn: !Ref Bus
FilterConfiguration:
Filters:
- Scope: DATA
Pattern: '{"detail-type":["async"]}'
BatchConfiguration:
MaxBatchSize: 1
InvokeConfiguration:
TargetArn: !GetAtt AsyncFunction.Arn
RoleArn: !GetAtt DeliveryRole.Arn
LambdaParameters:
InvocationType: EVENT
OnFailureConfiguration:
Arn: !GetAtt FailureQueue.Arn
SyncSubscriber:
Type: AWS::EventsV2::Subscriber
Properties:
Name: recursion-test-sync
EventBusArn: !Ref Bus
FilterConfiguration:
Filters:
- Scope: DATA
Pattern: '{"detail-type":["sync"]}'
BatchConfiguration:
MaxBatchSize: 1
InvokeConfiguration:
TargetArn: !GetAtt SyncFunction.Arn
RoleArn: !GetAtt DeliveryRole.Arn
LambdaParameters:
InvocationType: REQUEST_RESPONSE
RetryPolicy:
MaxRetryAttempts: 2
MaxEventAgeInSeconds: 600
OnFailureConfiguration:
Arn: !GetAtt FailureQueue.Arn
Outputs:
BusArn:
Value: !Ref Bus
The first event was published from a local environment using a newer version of boto3 with the following code.
import json
import boto3
client = boto3.client("eventbridgev2")
client.put_events(
EventBusArn="<Stack output BusArn>",
Entries=[{
"Source": "blog.recursion",
"DetailType": "async", # Use "sync" to test the synchronous loop
"Detail": json.dumps({"hop": 1}),
}],
)
Looping with Asynchronous Invocation
When the first event was published to the subscriber with InvocationType set to EVENT, the loop stopped after the execution at hop count 16!
13:46:09.601 hop=1 Lineage=1:efad5874:0
13:46:10.015 hop=2 Lineage=2:efad5874:1
...
13:46:11.262 hop=15 Lineage=15:efad5874:14
13:46:11.363 hop=16 Lineage=16:efad5874:15
Log timestamps in this article are in UTC.
The Lineage value in _X_AMZN_TRACE_ID incremented by 1 with each invocation.
The stopped event was delivered to the SQS queue configured as the Lambda function's on-failure destination.
{
"version": "1.0",
"timestamp": "2026-10-27T13:46:11.572Z",
"requestContext": {
"requestId": "79c455e8-d3a3-4932-8ed3-161b9662ab58",
"functionArn": "arn:aws:lambda:ap-northeast-1:123456789012:function:recursion-test-v2-async:$LATEST",
"condition": "RecursiveInvocation",
"approximateInvokeCount": 0
},
"requestPayload": {
"detail-type": "async",
"source": "blog.recursion",
"detail": { "hop": 17 }
},
"responseContext": { "statusCode": 400 }
}
The condition is RecursiveInvocation, and approximateInvokeCount is 0. This indicates that the 17th invocation was stopped before the function code was executed.
A value of 1 was recorded in the CloudWatch RecursiveInvocationsDropped metric.

Looping with Synchronous Invocation
When InvocationType is REQUEST_RESPONSE, the loop also stopped at hop count 16.
With synchronous invocation, a stopped invocation counts as a delivery failure for the subscriber. Since the subscriber's retry count was set to 2, RecursiveInvocationsDropped recorded a value of 3, including the retries.
Once retries were exhausted, the following record was delivered to the subscriber's on-failure destination.
{
"id": "53f6f859-6014-44fb-957a-c3039c3574ac",
"version": "1.0",
"busArn": "arn:aws:events:ap-northeast-1:123456789012:event-busv2/recursion-test-v2/3kzoowo52jd5vtgpg3ymwjmid",
"subscriberArn": "arn:aws:events:ap-northeast-1:123456789012:subscriber/recursion-test-sync/39sr6y1xwjwmvr0j2zbk5q17h",
"targetArn": "arn:aws:lambda:ap-northeast-1:123456789012:function:recursion-test-v2-sync",
"errorCode": "CUSTOMER_VALIDATION",
"errorMessage": "The invocation request exceeded maximum recursion depth.",
"exhaustedRetryCondition": "MaximumRetryAttempts",
"retryAttempts": 2,
"failedMessages": [
{
"eventId": "313ed728-b42f-4e1a-b040-46accfed0a98",
"eventGroupId": "b09c76a7-b64e-4f37-aaf0-6e79333d29bd",
"deduplicationId": "b09c76a7-b64e-4f37-aaf0-6e79333d29bd",
"timestamp": "2026-10-27T13:46:11.503Z",
"targetRequestId": "b9537eb9-5735-4449-b8cc-18c42d6c9363"
}
]
}
Unlike the asynchronous case, the event body is not included here — only the event ID is recorded.
The fact that it was stopped due to a recursive loop can be determined from the errorMessage. Since the same event is sent on retry, it will be stopped again. When using synchronous invocation, it may be worth reviewing the retry count.
No Stop in Classic and Default Event Bus
For comparison, we created the same loop using the Classic event bus and the default event bus. Both were configured with rules and targets, and the Lambda function published to the same bus using PutEvents from the events client.
The result was that neither stopped until the hop count 30 safety measure.
13:34:50.561 hop=1 Lineage=1:f7dc5e10:0
...
13:34:54.601 hop=29 Lineage=29:f7dc5e10:28
13:34:54.718 hop=30 Lineage=30:f7dc5e10:29
The Lineage value incremented up to 29, so the trace header is propagated even in Classic. Our hypothesis is that it still doesn't stop because Classic is not included in the detection targets.
As a sanity check, we also created an SQS loop in the same account, and that stopped at the 16th invocation, so it doesn't appear to be an account-level configuration issue.
Incidentally, since we hadn't configured a DLQ for the source queue in the SQS loop test, the stopped messages kept being retried from SQS. RecursiveInvocationsDropped was recorded every minute, so please be careful when testing with SQS.
When Recursive Loop Is Allowed
We ran put-function-recursion-config on the asynchronous function to allow recursive loops.
aws lambda put-function-recursion-config \
--function-name recursion-test-v2-async \
--recursive-loop Allow
In the console, this can be changed from the "Concurrency and recursion detection" section under the Lambda function's "Configuration" tab.

When the first event was published with recursive loop allowed, the loop continued beyond 16 invocations and was stopped by the hop count 30 safety measure. Per-function settings work the same way with Custom Event Bus as with other services.
Health Dashboard Notification
Approximately 3 hours after the loop was stopped, an AWS_LAMBDA_RUNAWAY_TERMINATION_NOTIFICATION event arrived in the Health Dashboard. (According to the documentation, it can take up to 3.5 hours.)

Two events were delivered.
| Delivery time (Japan time) | Target function |
|---|---|
| 2026-10-28 01:40 | recursion-test-sqs |
| 2026-10-28 01:50 | recursion-test-v2-async、recursion-test-v2-sync |
The event description stated that Lambda had stopped the recursive loop, and that recursive loop detection can be disabled if the loop is intentional.
AWS Lambda has detected that one or more Lambda functions in your AWS Account are being invoked in a recursive loop with other AWS resources. To prevent unexpected charges from being billed to your AWS account, Lambda has stopped the recursive invocations listed in the 'Affected resources' tab.
Conclusion
In summary, the new Custom Event Bus stopped the Lambda function loop at the 16th invocation, but the traditional Custom Event Bus - Classic and the default event bus did not stop it.
Existing configurations built with Classic rules, and loops involving unsupported services such as DynamoDB Streams, will continue not to be stopped. Prepare accordingly with CloudWatch alarms on Lambda concurrent executions and invocation counts.