benzene.aws

Host Benzene handlers on AWS Lambda — API Gateway (HTTP), SQS, SNS, S3, EventBridge, DynamoDB Streams, Kinesis, Kafka/MSK, and direct Lambda-invoke event sources in one function — plus SNS/SQS/EventBridge/Kinesis/Lambda outbound clients and a self-hosted SQS consumer. Distribution: benzene-aws (depends on benzene-core, benzene-http).

pip install benzene-aws            # add [boto3] for the real outbound clients

Overview

One host, inner bindings selected by event shape (transport-bindings §1):

Settlement (transport-bindings §1, obligation 8). Every source but API Gateway and a synchronous direct invoke is one-way: a message is acknowledged only when its invocation ended isSuccessful (ignored included), and a failure, a missing result, a topic no handler serves or an uncaught error is handed back — reported in batchItemFailures, or raised — so Lambda redelivers it and its own retry / on-failure destination / DLQ configuration decides when to give up. The unit of settlement:

Source Shape Unit of settlement
SQS queue the message (reported by messageId; needs ReportBatchItemFailures)
SNS, S3 one-shot the whole invocation — the first failing record raises, later records don't run
EventBridge one-shot the one event
Kafka / MSK stream the whole invocation, across every topic-partition in it
DynamoDB Streams, Kinesis stream the record (reported by sequence number); Lambda resumes the shard from the lowest failure, so the records after it are redelivered too
direct invoke, invocation_type="Event" one-shot the one envelope

Two knobs, the same settlement=Settlement(...) keyword every one-way host takes (and AwsLambdaApp.from_definition(definition, settlement=...)):

from benzene.core import Settlement

AwsLambdaApp(router, settlement=Settlement(at_most_once=True))            # acknowledge everything
AwsLambdaApp(router, settlement=Settlement(ignore_unmatched_topics=True)) # a shared bus's other topics

at_most_once reports nothing and raises nothing — a failure is logged on benzene.settlement and dropped. ignore_unmatched_topics acknowledges a topic no handler serves (at any version) as ignored; a known topic at an unknown version is still a failure. A direct invoke is synchronous — the caller reads the envelope — but Lambda does not tell a function how it was invoked, so a function invoked with InvocationType="Event" (LambdaMessageSender(invocation_type="Event")) is deployed with AwsLambdaApp(..., invocation_type="Event"): a failure then raises so Lambda's asynchronous retry takes over, instead of returning an envelope nobody reads.

Topic for the attribute-carrying transports (SQS, SNS, Kafka) comes from the topic message attribute / header. The channel-less sources (S3, EventBridge, DynamoDB, Kinesis) have no metadata channel on the wire, so their topic comes from an injectable convention configured on the host — or, for DynamoDB, is derived from the change event as {tableName}:{eventName} (orders:INSERT: the table parsed from the record's eventSourceARN, the eventName verbatim). A direct invoke's topic is exactly what the caller sent, no convention involved.

AwsLambdaApp

from benzene.aws import AwsLambdaApp, to_lambda_handler

app = AwsLambdaApp(http_router=router, registry=registry)   # shares one pipeline across sources
handler = to_lambda_handler(app)                            # def handler(event, context)

benzene.aws also exports the per-source decoders behind these bindings for custom wiring: s3_record_envelope, eventbridge_envelope, dynamodb_record_envelope, kinesis_record_envelope, kafka_records / kafka_record_envelope, invoke_envelope, and the event_source classifier.

Outbound clients

All five implement benzene.core.MessageSender and use boto3 (a lazy, optional import). SNS/SQS have a native attribute channel, so the Benzene topic rides in the topic message attribute and headers as attributes. EventBridge/Kinesis have no metadata channel, so the sender leaves the domain payload as the wire body and embeds headers inside it, under the reserved benzene-headers key (mirrors .NET's Benzene.Clients.Aws.EventBridge) — keeping correlation/trace propagation intact without disturbing the payload shape a plain (non-Benzene) consumer of the stream/bus would see. Lambda's invoke Payload is the envelope already. A send failure maps to service-unavailable — or timeout when the client's own deadline elapsed (botocore's ReadTimeoutError/ConnectTimeoutError) — never a raise.

Azure Functions and Kubernetes services have no equivalent native "invoke another function directly" primitive — the cross-platform way to reach the same synchronous-call outcome is over HTTP or gRPC (benzene.http.HttpMessageSender / benzene.grpc.GrpcMessageSender), which is why LambdaMessageSender is AWS-only.

Self-hosted SQS consumer

Distinct from the Lambda SQS trigger above, benzene.aws.sqs_consumer polls a queue itself — the shape a long-running worker or a Kubernetes Deployment needs, rather than being invoked by a Lambda event source mapping. It mirrors benzene.kafka's self-hosted consumer.

from benzene.aws import SqsConsumerApp, run_sqs_consumer_loop, sqs_consumer_worker

app = SqsConsumerApp.from_definition(definition)
await app.handle_message(message)                      # one receive_message() dict -> Result
await run_sqs_consumer_loop(app, client, queue_url)    # long-poll -> dispatch -> delete on success

WorkerHost().add("sqs", sqs_consumer_worker(app, client, queue_url))   # ...or as one leg of many

Testing

benzene.aws.testing provides AwsLambdaTestHost with one send_* per source — send_http, send_sqs / send_sqs_event, send_sns, send_s3, send_eventbridge, send_dynamodb, send_kinesis, send_kafka, send_invoke — and a native-event builder behind each (ApiGatewayRequestBuilder, SqsEventBuilder, SnsEventBuilder, S3EventBuilder, EventBridgeEventBuilder, DynamoDbStreamBuilder, KinesisEventBuilder, KafkaLambdaEventBuilder; a direct invoke needs no builder, its Payload already is the envelope). The partial-batch sources (send_sqs*, send_dynamodb, send_kinesis) return an SqsBatchResponse — assert on response.batch_item_failures — mirroring how send_http returns a response object. send_invoke returns the decoded Result directly (benzene.core.decode_response) — the same thing a real LambdaMessageSender would resolve to on the caller's side.

The self-hosted SQS consumer has its own harness: SqsConsumerTestHost (send_sqs_consumer → the mapped Result), the SqsMessageBuilder (a receive_message()-shaped dict, distinct from the Lambda SqsEventBuilder), and RecordingSqsClient (a replay client whose deleted list a test asserts the loop's at-least-once behaviour against — a failed message is not deleted). Specialize the shared harness with create_test_host(StartUp).build_aws() / .build_sqs_consumer(). See Hosting on AWS Lambda and the runnable examples/aws_orders.

See also