Calling Amazon Bedrock from AWS Lambda
, 4 min read
Lambda and Bedrock pair well: neither has servers to manage, and both charge for what you use. The first call is about ten lines of code. Most of the work is in the details around it, such as permissions, timeouts and streaming.
This guide starts with a simple buffered function, then adds streaming.
Before you write code
Choose a model ID. Many newer models are only available through an inference profile, which routes requests across a set of Regions. Claude Sonnet 5, for example, uses us.anthropic.claude-sonnet-5 in the US and au.anthropic.claude-sonnet-5 in Australia, and in most Regions it has no in-Region option. Get the ID from the model card, and keep it in an environment variable so you can change models without redeploying code. If you have data residency requirements, check where a profile routes requests before you use it.
Enable the model once. There's no longer an access request page in commercial Regions. Access starts on first use, but the first invocation needs AWS Marketplace permissions, and Anthropic models also need a one-time use case form. Do this from the console rather than giving your function Marketplace permissions, which it doesn't need afterwards.
A buffered function
The Converse API gives you one request format across models, so it's a good default.
import json
import os
import boto3
from botocore.config import Config
# Model calls can take a while, so allow more than the SDK's default.
client = boto3.client(
"bedrock-runtime",
config=Config(read_timeout=120, retries={"mode": "adaptive", "max_attempts": 3}),
)
MODEL_ID = os.environ["MODEL_ID"]
def handler(event, context):
prompt = json.loads(event.get("body") or "{}").get("prompt")
if not prompt:
return {"statusCode": 400, "body": json.dumps({"error": "prompt is required"})}
response = client.converse(
modelId=MODEL_ID,
messages=[{"role": "user", "content": [{"text": prompt}]}],
inferenceConfig={"maxTokens": 2048},
)
# The content list can hold more than text, such as reasoning blocks.
blocks = response["output"]["message"]["content"]
text = "".join(block["text"] for block in blocks if "text" in block)
return {
"statusCode": 200,
"body": json.dumps(
{
"text": text,
"stopReason": response["stopReason"],
"usage": response["usage"],
}
),
}
Three details in this code matter:
- Don't assume the first block is the answer. Claude Sonnet 5 uses adaptive thinking by default, so
content[0]may not be text. Filter for the text blocks instead. - Return or log
stopReasonandusage. Amax_tokensstop means the answer was cut off, and thinking uses tokens from the same budget. Usage figures are also how you'll track cost per request. - Always validate input. Each request costs money, so limit prompt size and put authentication in front of the function.
Permissions
Converse needs bedrock:InvokeModel, and ConverseStream needs bedrock:InvokeModelWithResponseStream. With an inference profile, the role must allow both the profile and the foundation model in every Region the profile can route to. Missing the second part is a common cause of AccessDeniedException.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
"Resource": "arn:aws:bedrock:us-east-1:111122223333:inference-profile/us.anthropic.claude-sonnet-5"
},
{
"Effect": "Allow",
"Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
"Resource": "arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-5",
"Condition": {
"StringLike": {
"bedrock:InferenceProfileArn": "arn:aws:bedrock:us-east-1:111122223333:inference-profile/us.anthropic.claude-sonnet-5"
}
}
}
]
}
The condition means the model can only be called through that profile.
Timeouts
Several timeouts apply at once, and the shortest one wins:
- Lambda function timeout: the default is only 3 seconds, which is far too short for a model call. Set it above your longest expected response.
- SDK read timeout: set it explicitly, as in the code above, and keep it below the function timeout so you get a clear error.
- API Gateway: REST APIs buffer by default, with a 29-second limit unless you request an increase. Long responses often hit this first, and it's a good reason to stream.
Adding streaming
Streaming shows users the first words quickly instead of a long spinner. There's one catch: Lambda only supports response streaming natively on Node.js managed runtimes. Python needs a custom runtime or the Lambda Web Adapter, so many teams write this one function in Node.js.
import {
BedrockRuntimeClient,
ConverseStreamCommand,
} from "@aws-sdk/client-bedrock-runtime";
const client = new BedrockRuntimeClient({});
export const handler = awslambda.streamifyResponse(
async (event, responseStream) => {
const { prompt } = JSON.parse(event.body ?? "{}");
const stream = awslambda.HttpResponseStream.from(responseStream, {
statusCode: prompt ? 200 : 400,
headers: { "Content-Type": "text/event-stream" },
});
if (!prompt) {
stream.write(`data: ${JSON.stringify({ error: "prompt is required" })}\n\n`);
stream.end();
return;
}
try {
const response = await client.send(
new ConverseStreamCommand({
modelId: process.env.MODEL_ID,
messages: [{ role: "user", content: [{ text: prompt }] }],
inferenceConfig: { maxTokens: 2048 },
})
);
for await (const chunk of response.stream) {
// Skip reasoning deltas and metadata events.
const text = chunk.contentBlockDelta?.delta?.text;
if (text) stream.write(`data: ${JSON.stringify({ text })}\n\n`);
if (chunk.metadata?.usage) {
console.log(JSON.stringify({ usage: chunk.metadata.usage }));
}
}
} catch (error) {
// The 200 status is already sent, so report failure in the stream.
console.error(error);
stream.write(`data: ${JSON.stringify({ error: "generation failed" })}\n\n`);
} finally {
stream.end();
}
}
);
Note that once streaming has started, you can't change the status code. Clients need to handle an error event arriving partway through a response.
Exposing the function
You have two main options:
- Function URL: the simplest route. Set the invoke mode to
RESPONSE_STREAM. AvoidAuthType: NONEon a function like this, because anyone who finds the URL can spend your money. Function URLs also don't support streaming in a VPC. - API Gateway REST API: better if you want authorizers, usage plans and WAF. Set the response transfer mode to
STREAMand use theresponse-streaming-invocationsintegration URI. Streams can run for up to 15 minutes, with a 5-minute idle timeout on Regional endpoints but only 30 seconds on edge-optimized ones. Caching and VTL response transformations aren't available in this mode.
One billing surprise
Lambda doesn't stop when the client disconnects. If a user closes the tab, your function keeps running and you pay for its full duration, along with the model tokens it generates. Set maxTokens and the function timeout to sensible limits rather than the maximum.
AWS service details and limits were checked against the AWS documentation in November 2026. Bedrock changes quickly, so confirm model IDs and availability for your Region.