Your quickstart ran, and the output matched the tutorial. Then you swapped in your own function, and the first invoke returned AccessDenied, a timeout, or a vague 500 with nothing useful in the logs.
That gap is normal. It doesn't mean you did something wrong. This guide takes one example from a working Lambda function to something you can reasonably deploy: a function that calls an external API and stores the result in a database. Each step closes one gap, in the order that causes the least rework.
What a quickstart proves, and what it leaves out
The AWS getting-started guides help developers set up their environment and run services such as AWS Lambda and Amazon S3 (see AWS Getting Started). They provide a starting point for learning and using the platform. A write-up on why a successful quickstart does not prove a product is easy to implement draws the line clearly: a successful quickstart proves you can complete the demonstrated workflow under the conditions tested, and nothing more.
That write-up also lists questions that tend to come next:
- Which configuration values change?
- How are credentials managed?
- What permissions does the app need?
- Which resources are provisioned separately?
- How are errors handled when an external service is down?
- What should be verified before production?
It frames these as illustrative, not as evidence that any vendor's quickstart falls short. This guide treats them the same way: they are questions your own app has to answer, because a tutorial cannot know your app.
The running example (unexecuted)
Status: this code has not been executed. It has not been run against a live AWS account or a real external API. Treat it as a starting point, and run the verification step below before you trust it.
The function accepts an id, fetches data from an external API, stores it in a DynamoDB table, and returns it. If the API is down, it falls back to the last stored value.
Dependencies and versions: the Python standard library (json, os, time, logging, urllib) plus boto3 and botocore. AWS Lambda Python runtimes ship with boto3, though the bundled version changes over time. This guide does not pin a Python runtime or boto3 release, because none was tested. Pick a currently supported runtime from the Lambda documentation, then record what you actually used with python --version and pip show boto3.
import json
import logging
import os
import time
import urllib.request
import boto3
from botocore.config import Config
from botocore.exceptions import BotoCoreError, ClientError
logger = logging.getLogger()
logger.setLevel(logging.INFO)
## Fail fast at cold start if configuration is missing.
TABLE_NAME = os.environ["TABLE_NAME"]
API_URL = os.environ["API_URL"]
SECRET_ID = os.environ["SECRET_ID"]
API_TIMEOUT = float(os.environ.get("API_TIMEOUT_SECONDS", "3"))
boto_config = Config(connect_timeout=2, read_timeout=3, retries={"max_attempts": 2})
_table = boto3.resource("dynamodb", config=boto_config).Table(TABLE_NAME)
_secrets = boto3.client("secretsmanager", config=boto_config)
_api_key = None
def get_api_key():
global _api_key
if _api_key is None:
_api_key = _secrets.get_secret_value(SecretId=SECRET_ID)["SecretString"]
return _api_key
def fetch_external():
req = urllib.request.Request(
API_URL, headers={"Authorization": "Bearer " + get_api_key()}
)
with urllib.request.urlopen(req, timeout=API_TIMEOUT) as r:
return json.loads(r.read())
def respond(status, body):
return {"statusCode": status, "body": json.dumps(body)}
def handler(event, context):
item_id = (event or {}).get("id")
if not item_id:
return respond(400, {"error": "id is required"})
data = None
try:
data = fetch_external()
except (OSError, ValueError) as exc:
# OSError covers URLError, HTTPError and socket timeouts.
# ValueError covers bad JSON. Fall back to the cache below.
logger.warning("external API failed: %r", exc)
try:
if data is not None:
_table.put_item(Item={
"id": item_id,
"payload": json.dumps(data),
"updatedAt": int(time.time()),
})
return respond(200, {"id": item_id, "source": "api", "data": data})
cached = _table.get_item(Key={"id": item_id}).get("Item")
except (ClientError, BotoCoreError):
logger.exception("database call failed")
return respond(503, {"error": "storage unavailable"})
if cached:
return respond(200, {"id": item_id, "source": "cache",
"data": json.loads(cached["payload"])})
return respond(502, {"error": "upstream unavailable and no cached value"})
``` This pairs well with [go http server graceful shutdown: fix the common bugs](/go-http-server-graceful-shutdown-fix-the-common-bugs).
Notice what the code does not catch: a failure to read the secret. A missing or denied secret is a configuration bug. You want it to fail loudly instead of looking like an outage.
## The quickstart-to-production checklist
### 1. Move configuration out of the code
The sample probably hardcoded a URL, a table name, or a region. Replace each with an environment variable, and read it with `os.environ["NAME"]` instead of `.get()`. A missing variable then crashes at cold start with a clear `KeyError`, instead of surfacing three calls later as a confusing 404.
Set `TABLE_NAME`, `API_URL`, `SECRET_ID`, and optionally `API_TIMEOUT_SECONDS` on the function. Keep one set per environment (dev, prod) so the same code deploys unchanged.
Set the function's own timeout above the worst case of your calls added together. In this example, that is the API timeout plus the database timeouts and retries. If the function timeout is shorter, Lambda kills the invocation before your fallback logic runs.
### 2. Take credentials out of the environment and the repo
Environment variables suit non-secret settings. An API token belongs in a secret store. The example keeps it in AWS Secrets Manager and passes only the secret's name through `SECRET_ID`. The function caches the value after the first read, so warm invocations skip another lookup.
Check your git history and shell history too. If you committed a key in the quickstart's sample file, it stays leaked after you delete the line, so rotate it.
### 3. Write least-privilege permissions
Check which role your quickstart ran under. For your own app, list the exact calls the code makes and grant only those. This function makes three: `dynamodb:GetItem`, `dynamodb:PutItem`, and `secretsmanager:GetSecretValue`.
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:REGION:ACCOUNT_ID:table/TABLE_NAME"
},
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:REGION:ACCOUNT_ID:secret:SECRET_NAME-*"
}
]
}
Replace the uppercase placeholders with your own values. Attach this policy alongside the basic logging permissions your function role already needs. Scope the resources to one table and one secret, never a wildcard. See a closer look at how to compare software development quotes like for like for additional background.
4. Provision the separate resources first
The sample ran on one resource. Your app needs several, and you create and configure each on its own:
- the DynamoDB table, with a partition key named
idto match the code - the secret
- the function's role
- any log retention you want
Create them before the first deploy. Record them in infrastructure code, or at least a README, so a second environment is reproducible.
Watch for a region mismatch. If the table lives in one region and the function in another, the resulting errors are easy to misread.
5. Decide what happens when a dependency is down
Every external call needs three decisions: how long to wait, whether to retry, and what to return when it still fails. The example sets a 3-second API timeout, caps SDK retries at 2, and returns a different response for each failure:
- API down, cached value exists: return 200 with
"source": "cache", so the caller can tell the data may be stale. - API down, nothing cached: return 502, an honest "upstream failed".
- Database down: return 503 and log the real exception.
Your right answers may differ. A payments call, for example, should probably not fall back to stale data. Make the choice deliberately instead of inheriting whatever the SDK does by default.
Verify it
Deploy, then invoke with the AWS CLI:
aws lambda invoke --function-name quickstart-to-prod \
--cli-binary-format raw-in-base64-out \
--payload '{"id":"order-1"}' out.json && cat out.json
Expected output on the happy path (the data field depends entirely on your API, so this is illustrative):
{"statusCode": 200, "body": "{\"id\": \"order-1\", \"source\": \"api\", \"data\": {\"rate\": 1.23}}"}
Also read: related topic: filesystem mcp server in claude desktop: safe setup guide
Then confirm the write landed by reading the item back from the table, using the console or aws dynamodb get-item. If the item is there with an updatedAt value, the config, secret, permissions, and table are wired correctly.
A common failure case: AccessDenied on the database
Suppose you attached a role that lacks dynamodb:PutItem. The function returns this:
{"statusCode": 503, "body": "{\"error\": \"storage unavailable\"}"}
The response hides the cause on purpose, since callers should not see internals. Open the function's CloudWatch logs and find the logged exception. An AccessDenied error from AWS typically names the principal, the denied action, and the resource. Add exactly that action to the policy and nothing broader.
To test the other failure, set API_URL to an address that refuses connections. Invoke with an id you already stored and expect "source": "cache". Invoke with a new id and expect a 502. If you see both, your fallback logic works.
Pre-production verification table
| Check | How to verify | Pass condition |
| --- | --- | --- |
| All config comes from environment variables | Remove one variable and invoke | Fast, clear KeyError at startup |
| No secrets in code or repo | Search the repo and its history for the token | No matches; leaked keys rotated |
| Role grants only needed actions | Compare the policy to the calls in the code | No wildcards on actions or resources |
| Resources exist in the right region | Check function, table, and secret regions | All match |
| Function timeout exceeds worst-case call time | Add up API timeout, SDK timeouts, and retries | Function timeout is larger |
| Happy path works end to end | Invoke, then read the item back | 200, source "api", item stored |
| API outage handled | Point API_URL at a dead address | Cached 200 or clean 502, no crash |
| Database failure is diagnosable | Remove a permission, invoke, read logs | 503 plus a logged cause |
| Logs and alarms exist | Trigger an error and look for it | Error visible where you will look |
What to expect next
Once this passes, the next questions are about load and change: concurrency limits, cost alerts, deploying updates without downtime, and rotating the secret. Neither a quickstart nor this checklist answers those, and that is fine. Run the table again after every meaningful change to permissions or dependencies, and expect your list of checks to grow with your app.



