I tried out Lambda's recursive loop detection now that it supports the new EventBridge Custom Event Bus

I tried out Lambda's recursive loop detection now that it supports the new EventBridge Custom Event Bus

Lambda recursive loop detection now supports EventBridge Custom Event Bus. Testing with an actual loop, it stopped on the new Custom Event Bus, but did not stop on the Classic or default event bus.
2026.10.28

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.

https://aws.amazon.com/about-aws/whats-new/2026/10/lambda-recursive-loops-eventbridge-custom/

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.

https://dev.classmethod.jp/articles/detecting-and-stopping-recursive-loops-in-aws-lambda-functions/

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.

Verification configuration diagram

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.

RecursiveInvocationsDropped metric graph

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.

Recursive loop detection edit screen

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.)

Affected resources in Health Dashboard

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.

脚注
  1. Amazon EventBridge event bus (Custom) - Amazon EventBridge ↩︎

  2. Use Lambda recursive loop detection to prevent infinite loops - AWS Lambda ↩︎

  3. botocore CHANGELOG 1.43.102 ↩︎

  4. Lambda function target - Amazon EventBridge ↩︎

Share this article