diff --git a/CHANGELOG.md b/CHANGELOG.md index 8daa3681..ed5de420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,3 @@ -## v1.1.0 - 2026-07-09 - -### Features - -- Add SnapStart support. The adapter notifies your web application at the SnapStart - boundary via two opt-in HTTP hooks — `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` - (before checkpoint) and `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` (after restore) — - so it can drain and re-establish connections. Each hook call is bounded by a - 60-second timeout. After restore the adapter refreshes its own HTTP client and - re-runs the readiness check (bounded by 10 seconds) before admitting traffic, and - it rejects external traffic to the hook paths with 403. - ---- - ## v1.0.1 - 2026-05-28 ### Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index aaf5bfba..ed6302ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -262,9 +262,9 @@ dependencies = [ [[package]] name = "aws_lambda_events" -version = "1.2.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d02c123e89527e7b424f74f52d11be0d17ef2887819323a42dcae1c7630ca53d" +checksum = "087b1b9233c7fc56623d72bb2f0b1fe915b19a0606aa3c09bcd1b902d9803e6c" dependencies = [ "base64", "bytes", @@ -1245,9 +1245,9 @@ dependencies = [ [[package]] name = "lambda_http" -version = "1.3.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e69eb3117d123f471f7d2b5d02b5afe150f54f9f7cff3264e578c9dd03675ad9" +checksum = "b0f0300091919bd7c3d953bd0fb6a7a170d24f333486e4129421c0f6aa1164ac" dependencies = [ "aws_lambda_events", "bytes", @@ -1270,9 +1270,9 @@ dependencies = [ [[package]] name = "lambda_runtime" -version = "1.3.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484647f147899f866a7b5db6aaa1671e943f89f4be76e2a383c96b1b27058294" +checksum = "23583c918bee8de7bc005ba2ad8ebd84529894bfd9a863cf24226bb6c7787690" dependencies = [ "async-stream", "base64", @@ -1295,9 +1295,9 @@ dependencies = [ [[package]] name = "lambda_runtime_api_client" -version = "1.1.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92e6500e47d17c1ffd3e6ad3ca224bb86382fc8b63414f6f20b1b2d98dfb7cf" +checksum = "7b4873061514cb57ffb6a599b77c46c65d6d783efe9bad8fd56b7cba7f0459ef" dependencies = [ "bytes", "futures-channel", @@ -1314,7 +1314,7 @@ dependencies = [ [[package]] name = "lambda_web_adapter" -version = "1.1.0" +version = "1.0.1" dependencies = [ "aws-credential-types", "aws-sigv4", diff --git a/Cargo.toml b/Cargo.toml index 1c5ce056..c9a1ff49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lambda_web_adapter" -version = "1.1.0" +version = "1.0.1" authors = [ "Harold Sun ", "David Calavera ", @@ -23,7 +23,7 @@ http-body = "1.0.1" http-body-util = "0.1.0" hyper = { version = "1.5.2", features = ["client"] } hyper-util = "0.1.10" -lambda_http = { version = "1.3.0", default-features = false, features = [ +lambda_http = { version = "1.1.1", default-features = false, features = [ "apigw_http", "apigw_rest", "alb", diff --git a/README.md b/README.md index 8b377204..c585c905 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ The same docker image can run on AWS Lambda, Amazon EC2, AWS Fargate, and local - Supports Amazon API Gateway Rest API and Http API endpoints, Lambda Function URLs, and Application Load Balancer - Supports Lambda managed runtimes, custom runtimes and docker OCI images - Supports Lambda Managed Instances for multi-concurrent request handling -- Supports Lambda SnapStart with before-checkpoint and after-restore hooks - Supports any web frameworks and languages, no new code dependency to include - Automatic encode binary response - Enables graceful shutdown @@ -67,8 +66,6 @@ The readiness check port/path and traffic port can be configured using environme | AWS_LWA_AUTHORIZATION_SOURCE | a header name to be replaced to `Authorization` | None | | AWS_LWA_ERROR_STATUS_CODES | HTTP status codes that will cause Lambda invocations to fail (e.g. "500,502-504") | None | | AWS_LWA_LAMBDA_RUNTIME_API_PROXY | overwrites `AWS_LAMBDA_RUNTIME_API` to allow proxying request | None | -| AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH | inner-app path the adapter POSTs to before a SnapStart snapshot (drain resources) | None | -| AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH | inner-app path the adapter POSTs to after a SnapStart restore (reconnect/reseed) | None | > **Deprecation Notice:** The following non-namespaced environment variables are deprecated and will be removed in version 2.0: > `HOST`, `READINESS_CHECK_PORT`, `READINESS_CHECK_PATH`, `READINESS_CHECK_PROTOCOL`, `REMOVE_BASE_PATH`, `ASYNC_INIT`. @@ -78,36 +75,6 @@ The readiness check port/path and traffic port can be configured using environme 👉 [Detailed configuration docs](https://aws.github.io/aws-lambda-web-adapter/configuration/environment-variables.html) -### SnapStart support - -When your function uses [Lambda SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html), -the adapter can notify your web application at the snapshot boundary so it can -drain and re-establish state (database connections, cached DNS, PRNG seeds, -unique identifiers). Both hooks are opt-in and independent. - -| Variable | When the adapter calls it | Use it to | -|---|---|---| -| `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` | Before the snapshot is taken | Drain/close resources that won't survive the snapshot | -| `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` | After restore, before serving traffic | Reconnect, refresh credentials, reseed randomness, regenerate unique IDs | - -Each hook is an empty `POST`; your application must respond with a `2xx` status. -A non-`2xx` response, a connection failure, or taking longer than 60 seconds to -respond fails the SnapStart phase (initialization for the before-checkpoint hook, -restore for the after-restore hook) instead of serving traffic against an -improperly prepared application. - -After restore, the adapter also automatically refreshes its own HTTP connection -to your application, so it never reuses a connection captured in the snapshot, and -then re-runs the readiness check before admitting traffic. If the application does -not report ready within 10 seconds of restore, the restore fails. - -> These hook paths are control-plane operations. External requests (via API -> Gateway or ALB) that target a configured hook path receive `403 Forbidden` and -> are never forwarded to your application, so choose paths your normal traffic -> does not use. - -See the [FastAPI with SnapStart example](examples/fastapi-snapstart-zip) for a complete, deployable application. - ## Examples - [FastAPI](examples/fastapi) @@ -117,8 +84,6 @@ See the [FastAPI with SnapStart example](examples/fastapi-snapstart-zip) for a c - [FastAPI with Response Streaming in Zip](examples/fastapi-response-streaming-zip) - [FastAPI with Response Streaming on Lambda Managed Instances](examples/fastapi-response-streaming-lmi) - [FastAPI Response Streaming Backend with IAM Auth](examples/fastapi-backend-only-response-streaming/) -- [FastAPI with SnapStart](examples/fastapi-snapstart) -- [FastAPI with SnapStart in Zip](examples/fastapi-snapstart-zip) - [Flask](examples/flask) - [Flask in Zip](examples/flask-zip) - [Serverless Django](https://github.com/aws-hebrew-book/serverless-django) by [@efi-mk](https://github.com/efi-mk) diff --git a/docs/guide/src/SUMMARY.md b/docs/guide/src/SUMMARY.md index 93ff43d9..bd47c0ff 100644 --- a/docs/guide/src/SUMMARY.md +++ b/docs/guide/src/SUMMARY.md @@ -23,7 +23,6 @@ - [Non-HTTP Event Triggers](./features/non-http-events.md) - [Multi-Tenancy](./features/multi-tenancy.md) - [Lambda Managed Instances](./features/managed-instances.md) -- [SnapStart](./features/snapstart.md) - [Graceful Shutdown](./features/graceful-shutdown.md) - [Base Path Removal](./features/base-path-removal.md) - [Authorization Header](./features/authorization-header.md) diff --git a/docs/guide/src/configuration/environment-variables.md b/docs/guide/src/configuration/environment-variables.md index c733522d..3ac78a76 100644 --- a/docs/guide/src/configuration/environment-variables.md +++ b/docs/guide/src/configuration/environment-variables.md @@ -19,8 +19,6 @@ All configuration is done through environment variables, set either in your Dock | `AWS_LWA_AUTHORIZATION_SOURCE` | Header name to replace with `Authorization` | None | | `AWS_LWA_ERROR_STATUS_CODES` | HTTP status codes that cause Lambda invocation failure (e.g. `500,502-504`) | None | | `AWS_LWA_LAMBDA_RUNTIME_API_PROXY` | Proxy URL for Lambda Runtime API requests | None | -| `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` | Inner-app path the adapter POSTs to before a SnapStart snapshot | None | -| `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` | Inner-app path the adapter POSTs to after a SnapStart restore | None | ## Deprecated Variables diff --git a/docs/guide/src/examples/overview.md b/docs/guide/src/examples/overview.md index ca011f99..2413e607 100644 --- a/docs/guide/src/examples/overview.md +++ b/docs/guide/src/examples/overview.md @@ -8,8 +8,6 @@ The repository includes working examples for many popular web frameworks, packag |---------|-----------|-----------| | [FastAPI](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi) | Docker | No | | [FastAPI in Zip](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-zip) | Zip | No | -| [FastAPI SnapStart](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-snapstart) | Docker | No | -| [FastAPI SnapStart Zip](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-snapstart-zip) | Zip | No | | [FastAPI Background Tasks](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-background-tasks) | Docker | No | | [FastAPI Response Streaming](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-response-streaming) | Docker | Yes | | [FastAPI Response Streaming Zip](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-response-streaming-zip) | Zip | Yes | diff --git a/docs/guide/src/features/snapstart.md b/docs/guide/src/features/snapstart.md deleted file mode 100644 index 7445728c..00000000 --- a/docs/guide/src/features/snapstart.md +++ /dev/null @@ -1,66 +0,0 @@ -# SnapStart - -[Lambda SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html) snapshots an initialized execution environment and restores it on later cold starts, reducing startup latency. Because the adapter runs your web application as a separate process, the application does not have direct access to the SnapStart lifecycle. The adapter bridges this gap with two optional HTTP hooks. - -## Hooks - -| Variable | When the adapter calls it | Use it to | -|----------|---------------------------|-----------| -| `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` | Before the snapshot is taken | Drain or close resources that will not survive the snapshot | -| `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` | After restore, before serving traffic | Reconnect, refresh credentials, reseed randomness, regenerate unique identifiers | - -Both hooks are opt-in and independent — each fires only when its variable is set. - -## How it works - -The adapter always registers for the SnapStart lifecycle; the Lambda runtime invokes the hooks only when your function runs under SnapStart. When it does, the adapter participates as follows: - -1. **Before checkpoint** — if `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set, the adapter sends an empty `POST` to that path on your application, then signals Lambda that it is ready for the snapshot. -2. **After restore** — Lambda restores the environment. The adapter first refreshes its own HTTP connection to your application (so it never reuses a connection captured in the snapshot); then, if `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set, sends an empty `POST` to that path; and finally re-runs the readiness check before admitting traffic. - -Each hook is an empty `POST`, and your application must respond with a `2xx` status. A non-`2xx` response, a connection failure, or taking longer than 60 seconds to respond fails the SnapStart phase — initialization for the before-checkpoint hook, restore for the after-restore hook — rather than serving traffic against an improperly prepared application. The final readiness check runs on every restore (whether or not an after-restore path is configured); if your application does not report ready within 10 seconds of restore, the restore fails. - -## Why you need the hooks - -State captured in a snapshot is shared across every restored environment. Two classes of problem follow: - -- **Stale connections.** Database connections, cached DNS, and keep-alive HTTP connections captured in the snapshot are dead by the time the environment is restored. Close them in the before-checkpoint hook and re-establish them in the after-restore hook. -- **Uniqueness and entropy.** Values seeded once at initialization — random number generators, UUID seeds, security tokens — become identical across every restored environment. Reseed them in the after-restore hook. - -## Securing the hook paths - -The hook paths are control-plane operations. External requests (via API Gateway or ALB) that target a configured hook path receive `403 Forbidden` and are never forwarded to your application. The guard matches the exact configured path, so choose paths your normal application traffic does not use (for example, `/snapstart/before` and `/snapstart/after`). - -## Example - -```python -from fastapi import FastAPI, Response - -app = FastAPI() -pool = None # your database/connection pool - - -@app.post("/snapstart/before") -async def before_checkpoint(): - # Close resources that won't survive the snapshot. - if pool is not None: - await pool.close() - return Response(status_code=200) - - -@app.post("/snapstart/after") -async def after_restore(): - # Re-establish resources and reseed anything that must be unique. - global pool - pool = await create_pool() - return Response(status_code=200) -``` - -Configure the function with: - -``` -AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH=/snapstart/before -AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH=/snapstart/after -``` - -See the [fastapi-snapstart-zip example](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-snapstart-zip) for a complete, deployable application. diff --git a/examples/fastapi-snapstart-zip/.gitignore b/examples/fastapi-snapstart-zip/.gitignore deleted file mode 100644 index 4808264d..00000000 --- a/examples/fastapi-snapstart-zip/.gitignore +++ /dev/null @@ -1,244 +0,0 @@ - -# Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode - -### Linux ### -*~ - -# temporary files which can be created if a process still has a handle open of a deleted file -.fuse_hidden* - -# KDE directory preferences -.directory - -# Linux trash folder which might appear on any partition or disk -.Trash-* - -# .nfs files are created when an open file is removed but is still being accessed -.nfs* - -### OSX ### -*.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### PyCharm ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff: -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/dictionaries - -# Sensitive or high-churn files: -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.xml -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml - -# Gradle: -.idea/**/gradle.xml -.idea/**/libraries - -# CMake -cmake-build-debug/ - -# Mongo Explorer plugin: -.idea/**/mongoSettings.xml - -## File-based project format: -*.iws - -## Plugin-specific files: - -# IntelliJ -/out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# Ruby plugin and RubyMine -/.rakeTasks - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -### PyCharm Patch ### -# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 - -# *.iml -# modules.xml -# .idea/misc.xml -# *.ipr - -# Sonarlint plugin -.idea/sonarlint - -### Python ### -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -.pytest_cache/ -nosetests.xml -coverage.xml -*.cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule.* - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ - -### VisualStudioCode ### -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -.history - -### Windows ### -# Windows thumbnail cache files -Thumbs.db -ehthumbs.db -ehthumbs_vista.db - -# Folder config file -Desktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# Build folder - -*/build/* - -# End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode \ No newline at end of file diff --git a/examples/fastapi-snapstart-zip/README.md b/examples/fastapi-snapstart-zip/README.md deleted file mode 100644 index 1a4156ec..00000000 --- a/examples/fastapi-snapstart-zip/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# FastAPI with Lambda SnapStart - -This example shows how to use Lambda Web Adapter's SnapStart hooks to drain and re-establish a connection pool around the snapshot/restore boundary, running a FastAPI application on the managed python runtime. - -### How does it work? - -We add the Lambda Web Adapter layer to the function and configure the wrapper script. - -1. attach Lambda Adapter layer to your function. This layer contains the Lambda Adapter binary and a wrapper script. - 1. x86_64: `arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerX86:28` - 2. arm64: `arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerArm64:28` -2. configure Lambda environment variable `AWS_LAMBDA_EXEC_WRAPPER` to `/opt/bootstrap`. This is a wrapper script included in the layer. -3. set function handler to a startup command: `run.sh`. The wrapper script will execute this command to boot up your application. - -To get more information of Wrapper script, please read Lambda documentation [here](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-modify.html#runtime-wrapper). - -#### SnapStart hooks - -[Lambda SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html) initializes your function, takes a snapshot of the initialized execution environment, and restores from that snapshot to serve invocations. Some resources do not survive the snapshot (open connections) and some values must not be shared across every restored environment (unique ids, seeds). The Lambda Web Adapter bridges the SnapStart lifecycle to your inner web app through two opt-in environment variables: - -- **Before the snapshot** — `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to `/snapstart/before`. The adapter sends an empty HTTP `POST` to this path. The app uses it to drain and close resources that will not survive the snapshot (in this example, it closes the connection pool). -- **After restore**, before traffic is served, the adapter performs three steps in order: - 1. It refreshes its own HTTP connection to the inner app, so it never reuses a connection captured in the snapshot (this is automatic — you do not configure or manage the adapter's client). - 2. `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/snapstart/after`. The adapter sends an empty HTTP `POST` to this path over the refreshed connection. The app uses it to re-establish connections and regenerate per-environment unique values (in this example, it reconnects the pool and generates a fresh `connection_id`). - 3. It re-runs the readiness check against your app before admitting traffic. - -Both hook routes must return a `2xx` status code. A non-2xx response, a connection failure, or taking longer than 60 seconds to respond fails the SnapStart phase. Likewise, if the readiness check does not pass within 10 seconds of restore, the restore fails — so traffic is never served against an app that has not finished recovering. - -These hook routes are protected: the adapter only allows them to be invoked internally during the SnapStart lifecycle. External callers that request `/snapstart/before` or `/snapstart/after` receive a `403 Forbidden`. - -### Build and Deploy - -Run the following commands to build and deploy the application to lambda. - -```bash -sam build --use-container -sam deploy --guided -``` -When the deployment completes, take note of FastAPISnapStartApi's Value. It is the API Gateway endpoint URL. - -### Verify it works - -Open FastAPISnapStartApi's URL in a browser. The `/` response shows `connected: true` and a `connection_id`, for example: - -```json -{ - "message": "Hello from FastAPI on Lambda SnapStart", - "connected": true, - "connection_id": 482913007 -} -``` - -After a SnapStart restore, the `connection_id` is regenerated rather than shared across every restored environment, because the adapter calls the after-restore hook (`/snapstart/after`), which reconnects the pool and generates a fresh id. This is exactly the behavior you want for any per-environment value (connections, random seeds, unique identifiers) that must not be duplicated across restored snapshots. diff --git a/examples/fastapi-snapstart-zip/app/main.py b/examples/fastapi-snapstart-zip/app/main.py deleted file mode 100644 index b9deba7a..00000000 --- a/examples/fastapi-snapstart-zip/app/main.py +++ /dev/null @@ -1,65 +0,0 @@ -import os -import random - -from fastapi import FastAPI, Response - -app = FastAPI() - - -class ConnectionPool: - """A stand-in for a real database/connection pool. - - In a real application these methods would open and close sockets to a - database. Here we just track state so the SnapStart lifecycle is observable. - """ - - def __init__(self): - self.connected = False - self.connection_id = None - - def connect(self): - # A fresh, unique id per environment — the kind of value that must be - # regenerated after a SnapStart restore so it is not shared across - # every restored environment. - self.connection_id = random.randint(1, 1_000_000_000) - self.connected = True - - def close(self): - self.connected = False - - -pool = ConnectionPool() -pool.connect() - - -@app.get("/") -async def root(): - return { - "message": "Hello from FastAPI on Lambda SnapStart", - "connected": pool.connected, - "connection_id": pool.connection_id, - } - - -@app.post("/snapstart/before") -async def before_checkpoint(): - """Called by the adapter before the snapshot is taken. - - Close resources that will not survive the snapshot. - """ - pool.close() - return Response(status_code=200) - - -@app.post("/snapstart/after") -async def after_restore(): - """Called by the adapter after the environment is restored. - - Re-establish connections and regenerate per-environment unique values. - """ - # Reseed from OS entropy first. Python's random module keeps its state in - # process memory, which is captured in the snapshot — without reseeding, - # every restored environment would draw the same "unique" value. - random.seed() - pool.connect() - return Response(status_code=200) diff --git a/examples/fastapi-snapstart-zip/app/requirements.txt b/examples/fastapi-snapstart-zip/app/requirements.txt deleted file mode 100644 index e61869b3..00000000 --- a/examples/fastapi-snapstart-zip/app/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -annotated-types==0.7.0 -anyio==4.6.2.post1 -fastapi==0.115.5 -idna==3.10 -pydantic==2.9.2 -pydantic-core==2.23.4 -sniffio==1.3.1 -starlette==0.41.2 -typing-extensions==4.12.2 -uvicorn==0.32.0 \ No newline at end of file diff --git a/examples/fastapi-snapstart-zip/app/run.sh b/examples/fastapi-snapstart-zip/app/run.sh deleted file mode 100755 index a630f442..00000000 --- a/examples/fastapi-snapstart-zip/app/run.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -PATH=$PATH:$LAMBDA_TASK_ROOT/bin \ - PYTHONPATH=$PYTHONPATH:/opt/python:$LAMBDA_RUNTIME_DIR \ - exec python -m uvicorn --port=$PORT main:app diff --git a/examples/fastapi-snapstart-zip/events/event.json b/examples/fastapi-snapstart-zip/events/event.json deleted file mode 100644 index a6197dea..00000000 --- a/examples/fastapi-snapstart-zip/events/event.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "body": "{\"message\": \"hello world\"}", - "resource": "/hello", - "path": "/hello", - "httpMethod": "GET", - "isBase64Encoded": false, - "queryStringParameters": { - "foo": "bar" - }, - "pathParameters": { - "proxy": "/path/to/resource" - }, - "stageVariables": { - "baz": "qux" - }, - "headers": { - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, sdch", - "Accept-Language": "en-US,en;q=0.8", - "Cache-Control": "max-age=0", - "CloudFront-Forwarded-Proto": "https", - "CloudFront-Is-Desktop-Viewer": "true", - "CloudFront-Is-Mobile-Viewer": "false", - "CloudFront-Is-SmartTV-Viewer": "false", - "CloudFront-Is-Tablet-Viewer": "false", - "CloudFront-Viewer-Country": "US", - "Host": "1234567890.execute-api.us-east-1.amazonaws.com", - "Upgrade-Insecure-Requests": "1", - "User-Agent": "Custom User Agent String", - "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", - "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", - "X-Forwarded-For": "127.0.0.1, 127.0.0.2", - "X-Forwarded-Port": "443", - "X-Forwarded-Proto": "https" - }, - "requestContext": { - "accountId": "123456789012", - "resourceId": "123456", - "stage": "prod", - "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", - "requestTime": "09/Apr/2015:12:34:56 +0000", - "requestTimeEpoch": 1428582896000, - "identity": { - "cognitoIdentityPoolId": null, - "accountId": null, - "cognitoIdentityId": null, - "caller": null, - "accessKey": null, - "sourceIp": "127.0.0.1", - "cognitoAuthenticationType": null, - "cognitoAuthenticationProvider": null, - "userArn": null, - "userAgent": "Custom User Agent String", - "user": null - }, - "path": "/prod/hello", - "resourcePath": "/hello", - "httpMethod": "POST", - "apiId": "1234567890", - "protocol": "HTTP/1.1" - } -} diff --git a/examples/fastapi-snapstart-zip/template.yaml b/examples/fastapi-snapstart-zip/template.yaml deleted file mode 100644 index ee28a35d..00000000 --- a/examples/fastapi-snapstart-zip/template.yaml +++ /dev/null @@ -1,44 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: > - FastAPI with Lambda SnapStart - -# More info about Globals: https://github.com/aws/serverless-application-model/blob/master/docs/globals.rst -Globals: - Function: - Timeout: 10 - -Resources: - FastAPISnapStartFunction: - Type: AWS::Serverless::Function - Properties: - CodeUri: app/ - Handler: run.sh - Runtime: python3.12 - MemorySize: 256 - AutoPublishAlias: live - SnapStart: - ApplyOn: PublishedVersions - Environment: - Variables: - AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap - PORT: 8000 - AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH: /snapstart/before - AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH: /snapstart/after - Layers: - - !Sub arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerX86:28 - Events: - ApiEvent: - Type: HttpApi - - -Outputs: - FastAPISnapStartApi: - Description: "API Gateway endpoint URL for Prod stage for FastAPI SnapStart function" - Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/" - FastAPISnapStartFunction: - Description: "FastAPI SnapStart Lambda Function ARN" - Value: !GetAtt FastAPISnapStartFunction.Arn - FastAPISnapStartIamRole: - Description: "Implicit IAM Role created for FastAPI SnapStart function" - Value: !GetAtt FastAPISnapStartFunctionRole.Arn diff --git a/examples/fastapi-snapstart/.gitignore b/examples/fastapi-snapstart/.gitignore deleted file mode 100644 index 4808264d..00000000 --- a/examples/fastapi-snapstart/.gitignore +++ /dev/null @@ -1,244 +0,0 @@ - -# Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode - -### Linux ### -*~ - -# temporary files which can be created if a process still has a handle open of a deleted file -.fuse_hidden* - -# KDE directory preferences -.directory - -# Linux trash folder which might appear on any partition or disk -.Trash-* - -# .nfs files are created when an open file is removed but is still being accessed -.nfs* - -### OSX ### -*.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### PyCharm ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff: -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/dictionaries - -# Sensitive or high-churn files: -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.xml -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml - -# Gradle: -.idea/**/gradle.xml -.idea/**/libraries - -# CMake -cmake-build-debug/ - -# Mongo Explorer plugin: -.idea/**/mongoSettings.xml - -## File-based project format: -*.iws - -## Plugin-specific files: - -# IntelliJ -/out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# Ruby plugin and RubyMine -/.rakeTasks - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -### PyCharm Patch ### -# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 - -# *.iml -# modules.xml -# .idea/misc.xml -# *.ipr - -# Sonarlint plugin -.idea/sonarlint - -### Python ### -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -.pytest_cache/ -nosetests.xml -coverage.xml -*.cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule.* - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ - -### VisualStudioCode ### -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -.history - -### Windows ### -# Windows thumbnail cache files -Thumbs.db -ehthumbs.db -ehthumbs_vista.db - -# Folder config file -Desktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# Build folder - -*/build/* - -# End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode \ No newline at end of file diff --git a/examples/fastapi-snapstart/README.md b/examples/fastapi-snapstart/README.md deleted file mode 100644 index aaa56e02..00000000 --- a/examples/fastapi-snapstart/README.md +++ /dev/null @@ -1,122 +0,0 @@ -# FastAPI with Lambda SnapStart (container image) - -This example shows how to use the Lambda Web Adapter's SnapStart hooks to drain and -re-establish a connection pool around the snapshot/restore boundary, packaged as a -**container image** (OCI) rather than a zip. - -> **Availability:** Lambda SnapStart for container images (OCI) is expected to launch -> in early July. Until it is available in your account/Region, deploying this example -> with `SnapStart: ApplyOn: PublishedVersions` on an `Image` package type will be -> rejected. The application itself runs unchanged with or without SnapStart — the -> hooks are simply not invoked when SnapStart is off. - -For the zip-packaged equivalent, see -[fastapi-snapstart-zip](../fastapi-snapstart-zip). - -## How does it work? - -The [Dockerfile](app/Dockerfile) copies the Lambda Web Adapter binary into -`/opt/extensions`: - -```dockerfile -FROM public.ecr.aws/docker/library/python:3.12-slim -COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.1.0 /lambda-adapter /opt/extensions/lambda-adapter -ENV PORT=8000 -WORKDIR /var/task -COPY requirements.txt ./ -RUN python -m pip install -r requirements.txt -COPY *.py ./ -CMD exec uvicorn --port=$PORT main:app -``` - -The two SnapStart hook endpoints are configured as function environment variables in -[`template.yaml`](template.yaml), keeping the image itself generic: - -```yaml - Environment: - Variables: - AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH: /snapstart/before - AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH: /snapstart/after -``` - -When the function runs under SnapStart, the adapter calls your application at the -snapshot boundary: - -- **Before the snapshot** — `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to - `/snapstart/before`. The adapter sends an empty HTTP `POST` to this path. The app uses - it to drain and close resources that will not survive the snapshot (in this example, - it closes the connection pool). -- **After restore**, before traffic is served, the adapter performs three steps in - order: - 1. It refreshes its own HTTP connection to the inner app, so it never reuses a - connection captured in the snapshot (this is automatic — you do not configure or - manage the adapter's client). - 2. `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/snapstart/after`. The adapter - sends an empty HTTP `POST` to this path over the refreshed connection. The app uses - it to re-establish connections and regenerate per-environment unique values (in - this example, it reconnects the pool and generates a fresh `connection_id`). - 3. It re-runs the readiness check against your app before admitting traffic. - -Both hook routes must return a `2xx` status code. A non-2xx response, a connection -failure, or taking longer than 60 seconds to respond fails the SnapStart phase. -Likewise, if the readiness check does not pass within 10 seconds of restore, the -restore fails — so traffic is never served against an app that has not finished -recovering. - -These hook routes are protected: the adapter only allows them to be invoked internally -during the SnapStart lifecycle. External callers that request `/snapstart/before` or -`/snapstart/after` receive a `403 Forbidden`. - -Because the adapter is packaged inside the image, the same container also runs -unchanged on Amazon ECS, Amazon EKS, or a local Docker host. - -## Pre-requisites - -* [AWS CLI](https://aws.amazon.com/cli/) -* [SAM CLI](https://github.com/aws/aws-sam-cli) -* [Docker](https://www.docker.com/products/docker-desktop) - -## Build and Deploy - -Build the container image and deploy with SAM: - -```bash -sam build -sam deploy --guided -``` - -When the deployment completes, take note of the `FastAPISnapStartApi` output — it is -the API Gateway endpoint URL. - -## Verify it works - -Open the API URL in a browser or with `curl`: - -```bash -curl https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/ -``` - -The response reports the connection state, for example: - -```json -{ - "message": "Hello from FastAPI on Lambda SnapStart (container image)", - "connected": true, - "connection_id": 426384719 -} -``` - -After a SnapStart restore, the `connection_id` is regenerated rather than shared across -every restored environment, because the adapter calls the after-restore hook -(`/snapstart/after`), which reconnects the pool and generates a fresh id. This is -exactly the behavior you want for any per-environment value (connections, random seeds, -unique identifiers) that must not be duplicated across restored snapshots. - -## Run the container locally - -The same image runs locally — without SnapStart, the hooks are simply never invoked: - -```bash -docker run -d -p 8000:8000 {ECR Image} -curl localhost:8000/ -``` diff --git a/examples/fastapi-snapstart/app/Dockerfile b/examples/fastapi-snapstart/app/Dockerfile deleted file mode 100644 index 98a4ad21..00000000 --- a/examples/fastapi-snapstart/app/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM public.ecr.aws/docker/library/python:3.12-slim -COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.1.0 /lambda-adapter /opt/extensions/lambda-adapter -ENV PORT=8000 -WORKDIR /var/task -COPY requirements.txt ./ -RUN python -m pip install -r requirements.txt -COPY *.py ./ -CMD exec uvicorn --port=$PORT main:app diff --git a/examples/fastapi-snapstart/app/main.py b/examples/fastapi-snapstart/app/main.py deleted file mode 100644 index 1476f5df..00000000 --- a/examples/fastapi-snapstart/app/main.py +++ /dev/null @@ -1,64 +0,0 @@ -import random - -from fastapi import FastAPI, Response - -app = FastAPI() - - -class ConnectionPool: - """A stand-in for a real database/connection pool. - - In a real application these methods would open and close sockets to a - database. Here we just track state so the SnapStart lifecycle is observable. - """ - - def __init__(self): - self.connected = False - self.connection_id = None - - def connect(self): - # A fresh, unique id per environment — the kind of value that must be - # regenerated after a SnapStart restore so it is not shared across - # every restored environment. - self.connection_id = random.randint(1, 1_000_000_000) - self.connected = True - - def close(self): - self.connected = False - - -pool = ConnectionPool() -pool.connect() - - -@app.get("/") -async def root(): - return { - "message": "Hello from FastAPI on Lambda SnapStart (container image)", - "connected": pool.connected, - "connection_id": pool.connection_id, - } - - -@app.post("/snapstart/before") -async def before_checkpoint(): - """Called by the adapter before the snapshot is taken. - - Close resources that will not survive the snapshot. - """ - pool.close() - return Response(status_code=200) - - -@app.post("/snapstart/after") -async def after_restore(): - """Called by the adapter after the environment is restored. - - Re-establish connections and regenerate per-environment unique values. - """ - # Reseed from OS entropy first. Python's random module keeps its state in - # process memory, which is captured in the snapshot — without reseeding, - # every restored environment would draw the same "unique" value. - random.seed() - pool.connect() - return Response(status_code=200) diff --git a/examples/fastapi-snapstart/app/requirements.txt b/examples/fastapi-snapstart/app/requirements.txt deleted file mode 100644 index 37a49601..00000000 --- a/examples/fastapi-snapstart/app/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -annotated-types==0.7.0 -anyio==4.6.2.post1 -fastapi==0.115.5 -idna==3.10 -pydantic==2.9.2 -pydantic-core==2.23.4 -sniffio==1.3.1 -starlette==0.41.2 -typing-extensions==4.12.2 -uvicorn==0.32.0 diff --git a/examples/fastapi-snapstart/events/event.json b/examples/fastapi-snapstart/events/event.json deleted file mode 100644 index 41be9620..00000000 --- a/examples/fastapi-snapstart/events/event.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "body": "{\"message\": \"hello world\"}", - "resource": "/hello", - "path": "/hello", - "httpMethod": "GET", - "isBase64Encoded": false, - "queryStringParameters": { - "foo": "bar" - }, - "pathParameters": { - "proxy": "/path/to/resource" - }, - "stageVariables": { - "baz": "qux" - }, - "headers": { - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, sdch", - "Accept-Language": "en-US,en;q=0.8", - "Cache-Control": "max-age=0", - "CloudFront-Forwarded-Proto": "https", - "CloudFront-Is-Desktop-Viewer": "true", - "CloudFront-Is-Mobile-Viewer": "false", - "CloudFront-Is-SmartTV-Viewer": "false", - "CloudFront-Is-Tablet-Viewer": "false", - "CloudFront-Viewer-Country": "US", - "Host": "1234567890.execute-api.us-east-1.amazonaws.com", - "Upgrade-Insecure-Requests": "1", - "User-Agent": "Custom User Agent String", - "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", - "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", - "X-Forwarded-For": "127.0.0.1, 127.0.0.2", - "X-Forwarded-Port": "443", - "X-Forwarded-Proto": "https" - }, - "requestContext": { - "accountId": "123456789012", - "resourceId": "123456", - "stage": "prod", - "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", - "requestTime": "09/Apr/2015:12:34:56 +0000", - "requestTimeEpoch": 1428582896000, - "identity": { - "cognitoIdentityPoolId": null, - "accountId": null, - "cognitoIdentityId": null, - "caller": null, - "accessKey": null, - "sourceIp": "127.0.0.1", - "cognitoAuthenticationType": null, - "cognitoAuthenticationProvider": null, - "userArn": null, - "userAgent": "Custom User Agent String", - "user": null - }, - "path": "/prod/hello", - "resourcePath": "/hello", - "httpMethod": "POST", - "apiId": "1234567890", - "protocol": "HTTP/1.1" - } - } diff --git a/examples/fastapi-snapstart/template.yaml b/examples/fastapi-snapstart/template.yaml deleted file mode 100644 index 3eb6e733..00000000 --- a/examples/fastapi-snapstart/template.yaml +++ /dev/null @@ -1,41 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: > - FastAPI with Lambda SnapStart (container image) - -# More info about Globals: https://github.com/aws/serverless-application-model/blob/master/docs/globals.rst -Globals: - Function: - Timeout: 10 - -Resources: - FastAPISnapStartFunction: - Type: AWS::Serverless::Function - Properties: - PackageType: Image - MemorySize: 256 - AutoPublishAlias: live - SnapStart: - ApplyOn: PublishedVersions - Environment: - Variables: - AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH: /snapstart/before - AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH: /snapstart/after - Events: - ApiEvent: - Type: HttpApi - Metadata: - Dockerfile: Dockerfile - DockerContext: ./app - DockerTag: python3.12-v1 - -Outputs: - FastAPISnapStartApi: - Description: "API Gateway endpoint URL for Prod stage for FastAPI SnapStart function" - Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/" - FastAPISnapStartFunction: - Description: "FastAPI SnapStart Lambda Function ARN" - Value: !GetAtt FastAPISnapStartFunction.Arn - FastAPISnapStartIamRole: - Description: "Implicit IAM Role created for FastAPI SnapStart function" - Value: !GetAtt FastAPISnapStartFunctionRole.Arn diff --git a/src/lib.rs b/src/lib.rs index 6887a1fa..d3eec116 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,7 +63,6 @@ //! with `InvokeMode: RESPONSE_STREAM`. mod readiness; -mod snapstart; // Environment variable names (AWS_LWA_ prefix) const ENV_PORT: &str = "AWS_LWA_PORT"; @@ -79,8 +78,6 @@ const ENV_ENABLE_COMPRESSION: &str = "AWS_LWA_ENABLE_COMPRESSION"; const ENV_INVOKE_MODE: &str = "AWS_LWA_INVOKE_MODE"; const ENV_AUTHORIZATION_SOURCE: &str = "AWS_LWA_AUTHORIZATION_SOURCE"; const ENV_ERROR_STATUS_CODES: &str = "AWS_LWA_ERROR_STATUS_CODES"; -const ENV_SNAPSTART_BEFORE_CHECKPOINT_PATH: &str = "AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH"; -const ENV_SNAPSTART_AFTER_RESTORE_PATH: &str = "AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH"; const ENV_LAMBDA_RUNTIME_API_PROXY: &str = "AWS_LWA_LAMBDA_RUNTIME_API_PROXY"; // Deprecated environment variable names (without prefix) @@ -102,13 +99,13 @@ const ENV_LAMBDA_RUNTIME_API: &str = "AWS_LAMBDA_RUNTIME_API"; // (Some(None)). static ORIGINAL_LAMBDA_RUNTIME_API: OnceLock> = OnceLock::new(); -use bytes::Bytes; use http::{ header::{HeaderName, HeaderValue}, Method, StatusCode, }; use http_body::Body as HttpBody; -use http_body_util::{combinators::BoxBody, BodyExt, Empty}; +use http_body_util::BodyExt; +use hyper::body::Incoming; use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::client::legacy::Client; use lambda_http::request::RequestContext; @@ -116,6 +113,7 @@ pub use lambda_http::tracing; use lambda_http::Body; pub use lambda_http::Error; use lambda_http::{Request, RequestExt, Response}; +use readiness::Checkpoint; use std::borrow::Cow; use std::fmt::Debug; use std::{ @@ -128,7 +126,8 @@ use std::{ }, time::Duration, }; -use tokio::time::timeout; +use tokio::{net::TcpStream, time::timeout}; +use tokio_retry::{strategy::FixedInterval, Retry}; use tower::{Service, ServiceBuilder}; use tower_http::compression::CompressionLayer; use url::Url; @@ -350,16 +349,6 @@ pub struct AdapterOptions { /// the adapter will return an error to Lambda instead of the response. /// This can be useful for triggering Lambda retry behavior. pub error_status_codes: Option>, - - /// Inner-app path POSTed before the SnapStart snapshot is taken. - /// When set, the adapter notifies the app so it can drain resources. - /// Default: `None` (phase skipped). - pub snapstart_before_checkpoint_path: Option, - - /// Inner-app path POSTed after the SnapStart restore completes. - /// When set, the adapter notifies the app so it can reconnect / reseed. - /// Default: `None` (phase skipped). - pub snapstart_after_restore_path: Option, } /// Helper to get env var with deprecation warning for old name @@ -446,8 +435,6 @@ impl Default for AdapterOptions { error_status_codes: env::var(ENV_ERROR_STATUS_CODES) .ok() .map(|codes| parse_status_codes(&codes)), - snapstart_before_checkpoint_path: env::var(ENV_SNAPSTART_BEFORE_CHECKPOINT_PATH).ok(), - snapstart_after_restore_path: env::var(ENV_SNAPSTART_AFTER_RESTORE_PATH).ok(), } } } @@ -550,7 +537,6 @@ fn strip_forbidden_header_bytes(s: &str) -> Cow<'_, [u8]> { #[derive(Clone)] pub struct Adapter { client: Arc>, - restored_client: Arc>>>, healthcheck_url: Url, healthcheck_protocol: Protocol, healthcheck_healthy_status: Vec, @@ -563,25 +549,18 @@ pub struct Adapter { invoke_mode: LambdaInvokeMode, authorization_source: Option, error_status_codes: Option>, - snapstart_before_checkpoint_path: Option, - snapstart_after_restore_path: Option, -} - -/// Builds the hyper client used to talk to the inner web application. -/// -/// Shared by [`Adapter::new`] and the SnapStart after-restore hook so the -/// post-restore client is built identically to the original. -fn build_client() -> Client { - let mut builder = Client::builder(hyper_util::rt::TokioExecutor::new()); - builder.pool_idle_timeout(Duration::from_secs(4)); - builder.build(HttpConnector::new()) } impl Adapter { /// Creates a new HTTP Adapter instance. /// /// This function initializes a new HTTP client configured to communicate with - /// your web application, using a 4-second idle timeout for connection pooling. + /// your web application. When Lambda SnapStart is detected + /// (`AWS_LAMBDA_INITIALIZATION_TYPE=snap-start`), connection pooling is + /// disabled to prevent stale connections after restore, where + /// `CLOCK_MONOTONIC` inconsistencies can cause hyper's pool to reuse dead + /// connections. Otherwise, a 4-second idle timeout is used for connection + /// pooling. /// /// # Arguments /// @@ -606,7 +585,19 @@ impl Adapter { /// let adapter = Adapter::new(&options).expect("Failed to create adapter"); /// ``` pub fn new(options: &AdapterOptions) -> Result, Error> { - let client = build_client(); + let mut builder = Client::builder(hyper_util::rt::TokioExecutor::new()); + + // When running under SnapStart, CLOCK_MONOTONIC can be inconsistent after + // restore, causing hyper's pool to reuse dead connections (hyper#3810, + // rust-lang/rust#79462). Disable pooling in that case. For localhost + // communication the overhead of new TCP connections is negligible. + if env::var("AWS_LAMBDA_INITIALIZATION_TYPE").as_deref() == Ok("snap-start") { + builder.pool_max_idle_per_host(0); + } else { + builder.pool_idle_timeout(Duration::from_secs(4)); + } + + let client = builder.build(HttpConnector::new()); let schema = "http"; @@ -650,7 +641,6 @@ impl Adapter { Ok(Adapter { client: Arc::new(client), - restored_client: Arc::new(OnceLock::new()), healthcheck_url, healthcheck_protocol: options.readiness_check_protocol, healthcheck_healthy_status: options.readiness_check_healthy_status.clone(), @@ -663,16 +653,8 @@ impl Adapter { invoke_mode: options.invoke_mode, authorization_source: options.authorization_source.clone(), error_status_codes: options.error_status_codes.clone(), - snapstart_before_checkpoint_path: options.snapstart_before_checkpoint_path.clone(), - snapstart_after_restore_path: options.snapstart_after_restore_path.clone(), }) } - - /// Returns the active inner-app HTTP client: the restored client if a - /// SnapStart restore has published one, otherwise the base client. - fn client(&self) -> &Arc> { - self.restored_client.get().unwrap_or(&self.client) - } } impl Adapter { @@ -796,18 +778,59 @@ impl Adapter { /// Uses a fixed 10ms interval between retry attempts and logs progress /// at increasing intervals (100ms, 500ms, 1s, 2s, 5s, 10s). async fn is_web_ready(&self, url: &Url, protocol: &Protocol) -> bool { - readiness::wait_until_ready(self.client(), url, *protocol, &self.healthcheck_healthy_status).await + let mut checkpoint = Checkpoint::new(); + Retry::spawn(FixedInterval::from_millis(10), || { + if checkpoint.lapsed() { + tracing::info!(url = %url.to_string(), "app is not ready after {}ms", checkpoint.next_ms()); + checkpoint.increment(); + } + self.check_web_readiness(url, protocol) + }) + .await + .is_ok() } /// Performs a single readiness check using the configured protocol. /// /// For HTTP: Makes a GET request and checks if the status code is in the healthy range. /// For TCP: Attempts to establish a TCP connection. - /// - /// Used by tests; `Adapter`'s own readiness path goes through [`is_web_ready`](Self::is_web_ready). - #[cfg(test)] async fn check_web_readiness(&self, url: &Url, protocol: &Protocol) -> Result<(), i8> { - readiness::check_web_readiness(self.client(), url, *protocol, &self.healthcheck_healthy_status).await + match protocol { + Protocol::Http => { + // url is already validated in Adapter::new(), this conversion should always succeed + // If it fails, it indicates a programming error, not a runtime condition + let uri: http::Uri = url + .as_str() + .parse() + .expect("BUG: healthcheck_url should be valid - validated in Adapter::new()"); + + match self.client.get(uri).await { + Ok(response) if self.healthcheck_healthy_status.contains(&response.status().as_u16()) => { + tracing::debug!("app is ready"); + Ok(()) + } + _ => { + tracing::trace!("app is not ready"); + Err(-1) + } + } + } + Protocol::Tcp => { + // url is already validated in Adapter::new(), host and port should exist + // If they don't, it indicates a programming error, not a runtime condition + let host = url + .host_str() + .expect("BUG: healthcheck_url should have host - validated in Adapter::new()"); + let port = url + .port() + .expect("BUG: healthcheck_url should have port - validated in Adapter::new()"); + + match TcpStream::connect(format!("{}:{}", host, port)).await { + Ok(_) => Ok(()), + Err(_) => Err(-1), + } + } + } } /// Starts the adapter and begins processing Lambda events. @@ -837,48 +860,16 @@ impl Adapter { /// # } /// ``` pub async fn run(self) -> Result<(), Error> { - let hooks = Arc::new(snapstart::SnapStartHooks::new( - self.restored_client.clone(), - self.client.clone(), - self.domain.clone(), - self.snapstart_before_checkpoint_path.clone(), - self.snapstart_after_restore_path.clone(), - self.healthcheck_url.clone(), - self.healthcheck_protocol, - self.healthcheck_healthy_status.clone(), - )); match (self.compression, self.invoke_mode) { (true, LambdaInvokeMode::Buffered) => { let svc = ServiceBuilder::new().layer(CompressionLayer::new()).service(self); - Self::register_and_run(lambda_http::runtime_concurrent(svc), hooks).await - } - (_, LambdaInvokeMode::Buffered) => { - Self::register_and_run(lambda_http::runtime_concurrent(self), hooks).await - } - (_, LambdaInvokeMode::ResponseStream) => { - Self::register_and_run(lambda_http::streaming_runtime_concurrent(self), hooks).await + lambda_http::run_concurrent(svc).await } + (_, LambdaInvokeMode::Buffered) => lambda_http::run_concurrent(self).await, + (_, LambdaInvokeMode::ResponseStream) => lambda_http::run_with_streaming_response_concurrent(self).await, } } - /// Registers the SnapStart hooks on `runtime` and starts the concurrent event loop. - /// - /// Each `run()` arm builds a different runtime type (buffered vs. streaming), - /// so the shared "register, then run" tail lives here as a generic helper. - async fn register_and_run( - runtime: lambda_http::lambda_runtime::Runtime, - hooks: Arc, - ) -> Result<(), Error> - where - S: lambda_http::Service - + Clone - + Send - + 'static, - S::Future: Send, - { - runtime.register_snapstart_resource(hooks).run_concurrent().await - } - /// Applies runtime API proxy configuration from environment variables. /// /// If `AWS_LWA_LAMBDA_RUNTIME_API_PROXY` is set, this method overwrites @@ -939,7 +930,7 @@ impl Adapter { /// 4. Strips the base path if configured /// 5. Forwards the request to the web application /// 6. Returns the response (or error if status code is in error_status_codes) - async fn fetch_response(&self, event: Request) -> Result>, Error> { + async fn fetch_response(&self, event: Request) -> Result, Error> { if self.async_init && !self.ready_at_init.load(Ordering::SeqCst) { self.is_web_ready(&self.healthcheck_url, &self.healthcheck_protocol) .await; @@ -965,17 +956,6 @@ impl Adapter { path = self.pass_through_path.as_str(); } - // Block external traffic to the SnapStart hook paths. These routes are - // control-plane operations driven only by the adapter's own hook calls - // (which target `domain` directly and never reach this function). - let is_hook_path = |configured: &Option| configured.as_deref().is_some_and(|p| p == path); - if is_hook_path(&self.snapstart_before_checkpoint_path) || is_hook_path(&self.snapstart_after_restore_path) { - tracing::warn!(path = %path, "rejecting external request to SnapStart hook path"); - return Ok(Response::builder() - .status(StatusCode::FORBIDDEN) - .body(Empty::::new().map_err(Error::from).boxed())?); - } - let mut req_headers = parts.headers; // include request context in http header "x-amzn-request-context" @@ -1029,7 +1009,7 @@ impl Adapter { }; let request = builder.body(Body::Binary(body_bytes))?; - let mut app_response = self.client().request(request).await?; + let mut app_response = self.client.request(request).await?; // Check if status code should trigger an error if let Some(error_codes) = &self.error_status_codes { @@ -1056,9 +1036,7 @@ impl Adapter { tracing::debug!(status = %app_response.status(), body_size = ?app_response.body().size_hint().lower(), app_headers = ?app_response.headers().clone(), "responding to lambda event"); - // Box the body into a uniform type so synthetic responses (e.g. the 403 - // hook-path guard) can share the return type with proxied responses. - Ok(app_response.map(|body| body.map_err(Error::from).boxed())) + Ok(app_response) } } @@ -1067,7 +1045,7 @@ impl Adapter { /// This allows the adapter to be used directly with the Lambda runtime, /// which expects a `Service` that can handle Lambda events. impl Service for Adapter { - type Response = Response>; + type Response = Response; type Error = Error; type Future = Pin> + Send>>; @@ -1100,35 +1078,6 @@ mod tests { assert_eq!(parse_status_codes(""), Vec::::new()); } - // Both cases live in one test because they mutate the same process-global env - // vars; splitting them lets Rust's parallel test runner interleave the - // set/remove calls and clobber each other's state. - #[test] - fn test_snapstart_paths() { - // Default case: unset env vars -> both None. - std::env::remove_var(ENV_SNAPSTART_BEFORE_CHECKPOINT_PATH); - std::env::remove_var(ENV_SNAPSTART_AFTER_RESTORE_PATH); - let options = AdapterOptions::default(); - assert_eq!(options.snapstart_before_checkpoint_path, None); - assert_eq!(options.snapstart_after_restore_path, None); - - // Set case: env vars present -> parsed into Some(..). - std::env::set_var(ENV_SNAPSTART_BEFORE_CHECKPOINT_PATH, "/snapstart/before"); - std::env::set_var(ENV_SNAPSTART_AFTER_RESTORE_PATH, "/snapstart/after"); - let options = AdapterOptions::default(); - assert_eq!( - options.snapstart_before_checkpoint_path.as_deref(), - Some("/snapstart/before") - ); - assert_eq!( - options.snapstart_after_restore_path.as_deref(), - Some("/snapstart/after") - ); - - std::env::remove_var(ENV_SNAPSTART_BEFORE_CHECKPOINT_PATH); - std::env::remove_var(ENV_SNAPSTART_AFTER_RESTORE_PATH); - } - #[tokio::test] async fn test_status_200_is_ok() { // Start app server @@ -1453,77 +1402,6 @@ mod tests { assert_eq!(200, response.status().as_u16()); } - #[tokio::test] - async fn test_external_request_to_hook_path_is_forbidden() { - // App server should NOT be called for a guarded path. - let app_server = MockServer::start(); - let guarded = app_server.mock(|when, then| { - when.path("/snapstart/after"); - then.status(200).body("should not be called"); - }); - - let options = AdapterOptions { - host: app_server.host(), - port: app_server.port().to_string(), - readiness_check_port: app_server.port().to_string(), - readiness_check_path: "/".to_string(), - snapstart_after_restore_path: Some("/snapstart/after".to_string()), - ..Default::default() - }; - let adapter = Adapter::new(&options).expect("Failed to create adapter"); - - // External request (ALB) targeting the guarded hook path. - let alb_req = lambda_http::request::LambdaRequest::Alb({ - let mut req = lambda_http::aws_lambda_events::alb::AlbTargetGroupRequest::default(); - req.http_method = Method::POST; - req.path = Some("/snapstart/after".into()); - req - }); - let mut request = Request::from(alb_req); - request.extensions_mut().insert(make_lambda_context(None)); - - let response = adapter - .fetch_response(request) - .await - .expect("guard returns Ok response"); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - - // The inner app must not have been contacted. - guarded.assert_calls(0); - } - - #[tokio::test] - async fn test_non_hook_path_is_proxied_normally() { - let app_server = MockServer::start(); - let hello = app_server.mock(|when, then| { - when.path("/hello"); - then.status(200).body("OK"); - }); - - let options = AdapterOptions { - host: app_server.host(), - port: app_server.port().to_string(), - readiness_check_port: app_server.port().to_string(), - readiness_check_path: "/".to_string(), - snapstart_after_restore_path: Some("/snapstart/after".to_string()), - ..Default::default() - }; - let adapter = Adapter::new(&options).expect("Failed to create adapter"); - - let alb_req = lambda_http::request::LambdaRequest::Alb({ - let mut req = lambda_http::aws_lambda_events::alb::AlbTargetGroupRequest::default(); - req.http_method = Method::GET; - req.path = Some("/hello".into()); - req - }); - let mut request = Request::from(alb_req); - request.extensions_mut().insert(make_lambda_context(None)); - - let response = adapter.fetch_response(request).await.expect("Request failed"); - assert_eq!(response.status(), StatusCode::OK); - hello.assert(); - } - #[tokio::test] async fn test_tenant_id_header_absent_when_no_tenant() { let app_server = MockServer::start(); @@ -1679,28 +1557,4 @@ mod tests { .expect("Request failed despite control bytes in request context path"); assert_eq!(200, response.status().as_u16()); } - - #[tokio::test] - async fn test_client_helper_returns_restored_when_set() { - let options = AdapterOptions { - host: "127.0.0.1".to_string(), - port: "8080".to_string(), - readiness_check_port: "8080".to_string(), - ..Default::default() - }; - let adapter = Adapter::new(&options).expect("Failed to create adapter"); - - // Before restore: client() returns the base client. - let base_ptr = Arc::as_ptr(adapter.client()) as *const (); - - // Publish a fresh client. - let fresh = Arc::new(build_client()); - let fresh_ptr = Arc::as_ptr(&fresh) as *const (); - assert!(adapter.restored_client.set(fresh).is_ok(), "set should succeed once"); - - // After restore: client() returns the restored client (different pointer). - let now_ptr = Arc::as_ptr(adapter.client()) as *const (); - assert_ne!(now_ptr, base_ptr); - assert_eq!(now_ptr, fresh_ptr); - } } diff --git a/src/readiness.rs b/src/readiness.rs index d346f608..a48e5096 100644 --- a/src/readiness.rs +++ b/src/readiness.rs @@ -1,81 +1,5 @@ use std::time::Instant; -use hyper_util::client::legacy::connect::HttpConnector; -use hyper_util::client::legacy::Client; -use lambda_http::Body; -use tokio::net::TcpStream; -use tokio_retry::{strategy::FixedInterval, Retry}; -use url::Url; - -use crate::Protocol; - -/// Performs a single readiness check against `url` using `protocol`. -/// -/// For HTTP: issues a GET via `client` and checks the status is in `healthy_status`. -/// For TCP: attempts to establish a TCP connection. Returns `Ok(())` when ready. -pub(crate) async fn check_web_readiness( - client: &Client, - url: &Url, - protocol: Protocol, - healthy_status: &[u16], -) -> Result<(), i8> { - match protocol { - Protocol::Http => { - // url is validated in Adapter::new(); this conversion should always succeed. - let uri: http::Uri = url - .as_str() - .parse() - .expect("BUG: healthcheck_url should be valid - validated in Adapter::new()"); - - match client.get(uri).await { - Ok(response) if healthy_status.contains(&response.status().as_u16()) => { - tracing::debug!("app is ready"); - Ok(()) - } - _ => { - tracing::trace!("app is not ready"); - Err(-1) - } - } - } - Protocol::Tcp => { - // url is validated in Adapter::new(); host and port should exist. - let host = url - .host_str() - .expect("BUG: healthcheck_url should have host - validated in Adapter::new()"); - let port = url - .port() - .expect("BUG: healthcheck_url should have port - validated in Adapter::new()"); - - match TcpStream::connect(format!("{}:{}", host, port)).await { - Ok(_) => Ok(()), - Err(_) => Err(-1), - } - } - } -} - -/// Waits for the web application to become ready, retrying on a fixed 10ms -/// interval and logging progress at increasing checkpoints. Returns `true` once -/// the app is ready. Callers bound the total wait with an external timeout. -pub(crate) async fn wait_until_ready( - client: &Client, - url: &Url, - protocol: Protocol, - healthy_status: &[u16], -) -> bool { - let mut checkpoint = Checkpoint::new(); - Retry::spawn(FixedInterval::from_millis(10), || { - if checkpoint.lapsed() { - tracing::info!(url = %url.to_string(), "app is not ready after {}ms", checkpoint.next_ms()); - checkpoint.increment(); - } - check_web_readiness(client, url, protocol, healthy_status) - }) - .await - .is_ok() -} - pub(crate) struct Checkpoint { start: Instant, interval_ms: u128, diff --git a/src/snapstart.rs b/src/snapstart.rs deleted file mode 100644 index 171654eb..00000000 --- a/src/snapstart.rs +++ /dev/null @@ -1,324 +0,0 @@ -//! SnapStart bridge: notifies the inner web application over HTTP at the -//! snapshot boundary and refreshes the adapter's HTTP client after restore. - -use std::sync::{Arc, OnceLock}; -use std::time::Duration; - -use hyper_util::client::legacy::connect::HttpConnector; -use hyper_util::client::legacy::Client; -use lambda_http::{Body, BoxFuture, Error, SnapStartResource}; -use tokio::time::timeout; -use url::Url; - -use crate::{build_client, readiness, Protocol}; - -/// Maximum time the adapter waits for an inner-app hook to respond before -/// failing the SnapStart phase. Bounds a hung or unresponsive hook so the -/// snapshot/restore lifecycle cannot stall indefinitely. -const HOOK_TIMEOUT: Duration = Duration::from_secs(60); - -/// Maximum time the adapter waits for the inner app to report ready after -/// restore (step 3). Tighter than [`HOOK_TIMEOUT`]: once the after-restore hook -/// has run, the app should become ready almost immediately, so a long stall here -/// indicates a failed restore rather than legitimate slow work. -const READINESS_TIMEOUT: Duration = Duration::from_secs(10); - -/// A [`SnapStartResource`] that bridges the Lambda SnapStart lifecycle to the -/// inner web application running behind the adapter. -pub(crate) struct SnapStartHooks { - /// Shared with the [`Adapter`](crate::Adapter); `after_restore` publishes the - /// fresh client here so invocations stop using pre-snapshot connections. - restored_client: Arc>>>, - /// Client used to make the hook calls themselves (the adapter's base client). - client: Arc>, - /// `http://host:port` of the inner application. - domain: Url, - before_checkpoint_path: Option, - after_restore_path: Option, - /// Readiness-check endpoint, protocol, and healthy statuses — shared with the - /// adapter so the post-restore readiness check (step 3) matches init behavior. - healthcheck_url: Url, - healthcheck_protocol: Protocol, - healthcheck_healthy_status: Vec, -} - -impl SnapStartHooks { - #[allow(clippy::too_many_arguments)] - pub(crate) fn new( - restored_client: Arc>>>, - client: Arc>, - domain: Url, - before_checkpoint_path: Option, - after_restore_path: Option, - healthcheck_url: Url, - healthcheck_protocol: Protocol, - healthcheck_healthy_status: Vec, - ) -> Self { - Self { - restored_client, - client, - domain, - before_checkpoint_path, - after_restore_path, - healthcheck_url, - healthcheck_protocol, - healthcheck_healthy_status, - } - } - - /// POSTs an empty body to `domain + path` using `client`. A non-2xx - /// response, a transport error, or exceeding [`HOOK_TIMEOUT`] is an error. - async fn post_hook(client: &Client, domain: &Url, path: &str) -> Result<(), Error> { - Self::post_hook_with_timeout(client, domain, path, HOOK_TIMEOUT).await - } - - /// Implementation of [`post_hook`](Self::post_hook) with an explicit timeout, - /// so tests can exercise the timeout path without waiting [`HOOK_TIMEOUT`]. - async fn post_hook_with_timeout( - client: &Client, - domain: &Url, - path: &str, - hook_timeout: Duration, - ) -> Result<(), Error> { - let mut url = domain.clone(); - url.set_path(path); - let req = hyper::Request::builder() - .method(hyper::Method::POST) - .uri(url.to_string()) - .body(Body::Empty)?; - let resp = timeout(hook_timeout, client.request(req)) - .await - .map_err(|_| Error::from(format!("SnapStart hook POST {path} timed out after {hook_timeout:?}")))??; - if !resp.status().is_success() { - return Err(Error::from(format!( - "SnapStart hook POST {path} returned non-success status: {}", - resp.status() - ))); - } - Ok(()) - } -} - -impl SnapStartResource for SnapStartHooks { - fn before_snapshot(&self) -> BoxFuture<'_, Result<(), Error>> { - Box::pin(async move { - if let Some(path) = self.before_checkpoint_path.as_deref() { - Self::post_hook(&self.client, &self.domain, path).await?; - } - Ok(()) - }) - } - - fn after_restore(&self) -> BoxFuture<'_, Result<(), Error>> { - Box::pin(async move { - // 1. Publish a fresh client FIRST so the hook POST below (and all - // subsequent invocations) use post-restore connections rather - // than stale pre-snapshot ones. Ignore "already set". - let fresh = Arc::new(build_client()); - let _ = self.restored_client.set(fresh.clone()); - - // 2. Notify the app over the fresh client. Failure fails the restore; - // the fresh client stays published regardless. - if let Some(path) = self.after_restore_path.as_deref() { - Self::post_hook(&fresh, &self.domain, path).await?; - } - - // 3. Confirm the app is serving again before traffic is admitted. - self.check_readiness_with_timeout(&fresh, READINESS_TIMEOUT).await?; - - Ok(()) - }) - } -} - -impl SnapStartHooks { - /// Step 3 of [`after_restore`](SnapStartResource::after_restore): retry-until-ready - /// over `client`, bounded by `readiness_timeout`. A timeout or an unready app is an - /// error, which fails the restore (reported to `/restore/error`). Split out with an - /// explicit timeout so tests can exercise the failure path without waiting - /// [`READINESS_TIMEOUT`]. - async fn check_readiness_with_timeout( - &self, - client: &Client, - readiness_timeout: Duration, - ) -> Result<(), Error> { - let ready = timeout( - readiness_timeout, - readiness::wait_until_ready( - client, - &self.healthcheck_url, - self.healthcheck_protocol, - &self.healthcheck_healthy_status, - ), - ) - .await - .map_err(|_| { - Error::from(format!( - "SnapStart after-restore readiness check timed out after {readiness_timeout:?}" - )) - })?; - if !ready { - return Err(Error::from("SnapStart after-restore readiness check failed")); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use httpmock::MockServer; - - /// Builds hooks pointed at `server`, with the readiness check targeting - /// `health_path` on the same server. - fn hooks_with_health( - server: &MockServer, - before: Option<&str>, - after: Option<&str>, - health_path: &str, - ) -> SnapStartHooks { - let domain: Url = format!("http://{}:{}", server.host(), server.port()).parse().unwrap(); - let healthcheck_url: Url = format!("http://{}:{}{}", server.host(), server.port(), health_path) - .parse() - .unwrap(); - SnapStartHooks::new( - Arc::new(OnceLock::new()), - Arc::new(build_client()), - domain, - before.map(str::to_string), - after.map(str::to_string), - healthcheck_url, - Protocol::Http, - (100..500).collect(), - ) - } - - /// Builds hooks with a readiness check that always passes (a mocked `/health` - /// returning 200), for tests focused on the before/after hook behavior. - fn hooks(server: &MockServer, before: Option<&str>, after: Option<&str>) -> SnapStartHooks { - server.mock(|when, then| { - when.path("/health"); - then.status(200); - }); - hooks_with_health(server, before, after, "/health") - } - - #[tokio::test] - async fn before_snapshot_posts_when_set() { - let server = MockServer::start(); - let m = server.mock(|when, then| { - when.method(httpmock::Method::POST).path("/before"); - then.status(200); - }); - let h = hooks(&server, Some("/before"), None); - assert!(h.before_snapshot().await.is_ok()); - m.assert(); - } - - #[tokio::test] - async fn before_snapshot_noop_when_unset() { - let server = MockServer::start(); - let h = hooks(&server, None, None); - assert!(h.before_snapshot().await.is_ok()); - } - - #[tokio::test] - async fn before_snapshot_non_2xx_is_error() { - let server = MockServer::start(); - server.mock(|when, then| { - when.method(httpmock::Method::POST).path("/before"); - then.status(500); - }); - let h = hooks(&server, Some("/before"), None); - assert!(h.before_snapshot().await.is_err()); - } - - #[tokio::test] - async fn after_restore_publishes_client_then_posts() { - let server = MockServer::start(); - let m = server.mock(|when, then| { - when.method(httpmock::Method::POST).path("/after"); - then.status(200); - }); - let h = hooks(&server, None, Some("/after")); - assert!(h.restored_client.get().is_none()); - assert!(h.after_restore().await.is_ok()); - assert!(h.restored_client.get().is_some(), "fresh client published"); - m.assert(); - } - - #[tokio::test] - async fn after_restore_publishes_client_even_when_hook_fails() { - let server = MockServer::start(); - server.mock(|when, then| { - when.method(httpmock::Method::POST).path("/after"); - then.status(503); - }); - let h = hooks(&server, None, Some("/after")); - let result = h.after_restore().await; - assert!(result.is_err(), "hook failure returns Err"); - assert!( - h.restored_client.get().is_some(), - "client published despite hook failure" - ); - } - - #[tokio::test] - async fn post_hook_times_out_when_app_is_slow() { - let server = MockServer::start(); - // The app takes far longer to respond than the timeout we pass below. - server.mock(|when, then| { - when.method(httpmock::Method::POST).path("/slow"); - then.status(200).delay(Duration::from_secs(2)); - }); - let domain: Url = format!("http://{}:{}", server.host(), server.port()).parse().unwrap(); - let client = build_client(); - - let result = - SnapStartHooks::post_hook_with_timeout(&client, &domain, "/slow", Duration::from_millis(100)).await; - - let err = result.expect_err("slow hook should time out"); - assert!(err.to_string().contains("timed out"), "unexpected error: {err}"); - } - - #[tokio::test] - async fn after_restore_publishes_client_when_path_unset() { - let server = MockServer::start(); - let h = hooks(&server, None, None); - assert!(h.after_restore().await.is_ok()); - assert!(h.restored_client.get().is_some()); - } - - #[tokio::test] - async fn after_restore_readiness_check_runs_over_fresh_client() { - // No after-restore POST configured: step 3 must still run and pass. - let server = MockServer::start(); - let health = server.mock(|when, then| { - when.path("/ready"); - then.status(200); - }); - let h = hooks_with_health(&server, None, None, "/ready"); - assert!(h.after_restore().await.is_ok()); - health.assert(); - } - - #[tokio::test] - async fn check_readiness_times_out_when_app_never_ready() { - // Health endpoint always reports unhealthy; the bounded readiness check - // should give up and fail rather than retry forever. - let server = MockServer::start(); - server.mock(|when, then| { - when.path("/never"); - then.status(503); - }); - let h = hooks_with_health(&server, None, None, "/never"); - let client = build_client(); - - let result = h - .check_readiness_with_timeout(&client, Duration::from_millis(100)) - .await; - - let err = result.expect_err("unready app should fail the readiness check"); - assert!(err.to_string().contains("timed out"), "unexpected error: {err}"); - } -} diff --git a/tests/integ_tests/main.rs b/tests/integ_tests/main.rs index d1227ddb..0238618e 100644 --- a/tests/integ_tests/main.rs +++ b/tests/integ_tests/main.rs @@ -13,6 +13,7 @@ use httpmock::{ Method::{DELETE, GET, POST, PUT}, MockServer, }; +use hyper::body::Incoming; use lambda_http::Body; use lambda_http::Context; use lambda_web_adapter::{Adapter, AdapterOptions, LambdaInvokeMode, Protocol}; @@ -24,7 +25,7 @@ use flate2::Compression; use http_body_util::BodyExt; use lambda_http::lambda_runtime::Config; use serde_json::json; -use tower_http::compression::CompressionLayer; +use tower_http::compression::{CompressionBody, CompressionLayer}; #[test] fn test_adapter_options_from_env() { @@ -1161,20 +1162,12 @@ async fn test_http_tcp_readiness_check() { assert_eq!("TCP Ready", body_to_string(response).await); } -async fn body_to_string(res: Response) -> String -where - B: http_body::Body, - B::Error: std::fmt::Debug, -{ +async fn body_to_string(res: Response) -> String { let body_bytes = res.collect().await.unwrap().to_bytes(); String::from_utf8_lossy(&body_bytes).to_string() } -async fn compressed_body_to_string(res: Response) -> String -where - B: http_body::Body, - B::Error: std::fmt::Debug, -{ +async fn compressed_body_to_string(res: Response>) -> String { let body_bytes = res.collect().await.unwrap().to_bytes(); decode_reader(&body_bytes).unwrap() }