diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..8416c23
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,12 @@
+version: 2
+updates:
+ - package-ecosystem: "nuget"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "wednesday"
+ open-pull-requests-limit: 25
+ groups:
+ dotnet:
+ patterns:
+ - "*"
\ No newline at end of file
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
new file mode 100644
index 0000000..d524c33
--- /dev/null
+++ b/.github/workflows/build.yaml
@@ -0,0 +1,27 @@
+name: Build
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ - beta
+ - release/*
+ tags:
+ - v*
+ paths-ignore:
+ - docs/**
+ - README.md
+ - mkdocs.yml
+ - requirements.txt
+permissions: write-all
+jobs:
+ build:
+ uses: LayeredCraft/devops-templates/.github/workflows/package-build.yaml@v6.1
+ with:
+ hasTests: true
+ useMtpRunner: true
+ testDirectory: "test"
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
+ secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..92f65d1
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,76 @@
+name: Deploy Documentation
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - 'docs/**'
+ - 'mkdocs.yml'
+ - '.github/workflows/docs.yml'
+ pull_request:
+ branches: [ main ]
+ paths:
+ - 'docs/**'
+ - 'mkdocs.yml'
+ - '.github/workflows/docs.yml'
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: "pages"
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+
+ - name: Cache dependencies
+ uses: actions/cache@v4
+ with:
+ key: mkdocs-material-${{ hashFiles('requirements.txt') }}
+ path: ~/.cache/pip
+ restore-keys: |
+ mkdocs-material-
+
+ - name: Install dependencies
+ run: |
+ pip install mkdocs-material
+ pip install mkdocs-minify-plugin
+
+ - name: Setup Pages
+ id: pages
+ uses: actions/configure-pages@v4
+
+ - name: Build documentation
+ run: |
+ mkdocs build --clean
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: site
+
+ deploy:
+ if: github.ref == 'refs/heads/main'
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
\ No newline at end of file
diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml
new file mode 100644
index 0000000..00e567b
--- /dev/null
+++ b/.github/workflows/pr-build.yaml
@@ -0,0 +1,22 @@
+name: PR Build
+
+on:
+ pull_request:
+ branches:
+ - main
+permissions: write-all
+jobs:
+ build:
+ uses: LayeredCraft/devops-templates/.github/workflows/pr-build.yaml@v6.1
+ with:
+ solution: LayeredCraft.Lambda.AspNetCore.HostingExtensions.sln
+ hasTests: true
+ useMtpRunner: true
+ testDirectory: "test"
+ enableCodeCoverage: true
+ coverageThreshold: 80
+ dotnetVersion: |
+ 8.0.x
+ 9.0.x
+ runCdk: false
+ secrets: inherit
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index ce89292..640f6cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -416,3 +416,15 @@ FodyWeavers.xsd
*.msix
*.msm
*.msp
+
+.idea
+/site
+
+# macOS
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..53d9988
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,54 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Overview
+
+This is a .NET library that provides extensions and middleware for Amazon.Lambda.AspNetCoreServer.Hosting, designed specifically for AWS Lambda hosting environments. It delivers improved reliability, observability, and developer experience for ASP.NET Core applications running on AWS Lambda.
+
+## Project Structure
+
+- **src/LayeredCraft.Lambda.AspNetCore.HostingExtensions/** - Main library project containing middleware and extensions
+- **Middleware/LambdaTimeoutLinkMiddleware.cs** - Core middleware that links Lambda timeout with HTTP request cancellation
+
+## Key Architecture
+
+The main component is `LambdaTimeoutLinkMiddleware` which provides timeout handling by:
+- Creating a cancellation token that triggers on either client disconnect or Lambda timeout
+- Replacing `HttpContext.RequestAborted` with a linked token during request processing
+- Setting appropriate status codes (504 for timeout, 499 for client disconnect)
+- Operating as pass-through in local development where `ILambdaContext` is unavailable
+
+## Development Commands
+
+### Build
+```bash
+dotnet build
+```
+
+### Test
+```bash
+dotnet test
+```
+
+### Restore Dependencies
+```bash
+dotnet restore
+```
+
+## Target Frameworks
+
+The project targets both .NET 8.0 and .NET 9.0 (`net8.0;net9.0`).
+
+## Key Dependencies
+
+- **Amazon.Lambda.AspNetCoreServer** (v9.2.0) - AWS Lambda ASP.NET Core hosting
+- **LayeredCraft.StructuredLogging** (v1.1.1.8) - Structured logging utilities
+
+## Package Information
+
+This is a packable library (NuGet package) with:
+- MIT license
+- SourceLink support for debugging
+- Symbol packages (.snupkg) for enhanced debugging experience
+- Version prefix: 2.0.1
\ No newline at end of file
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..c1421be
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,29 @@
+
+
+ 2.0.1
+
+ MIT
+
+
+ https://github.com/LayeredCraft/lambda-aspnetcore-hosting-extensions
+ git
+ Nick Cipollina
+ https://github.com/LayeredCraft/lambda-aspnetcore-hosting-extensions
+
+
+ false
+ true
+ true
+ snupkg
+ true
+ true
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/LayeredCraft.Lambda.AspNetCore.HostingExtensions.sln b/LayeredCraft.Lambda.AspNetCore.HostingExtensions.sln
new file mode 100644
index 0000000..d1699d2
--- /dev/null
+++ b/LayeredCraft.Lambda.AspNetCore.HostingExtensions.sln
@@ -0,0 +1,100 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{97C05F93-574D-41AA-A848-C4B7731FACC0}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{C52DAB93-ADC4-423B-9820-C705A55436A3}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "git", "git", "{77FDD507-2994-4D64-8C35-232E81E75FF0}"
+ ProjectSection(SolutionItems) = preProject
+ .gitignore = .gitignore
+ .github\dependabot.yml = .github\dependabot.yml
+ .github\workflows\build.yaml = .github\workflows\build.yaml
+ .github\workflows\docs.yml = .github\workflows\docs.yml
+ .github\workflows\pr-build.yaml = .github\workflows\pr-build.yaml
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B22CDAC2-6A62-42F1-95FC-326861BED2A5}"
+ ProjectSection(SolutionItems) = preProject
+ README.md = README.md
+ LICENSE = LICENSE
+ Directory.Build.props = Directory.Build.props
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LayeredCraft.Lambda.AspNetCore.HostingExtensions", "src\LayeredCraft.Lambda.AspNetCore.HostingExtensions\LayeredCraft.Lambda.AspNetCore.HostingExtensions.csproj", "{789D3B32-3F92-4628-8416-08F3927E1B57}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{93154236-6904-4ED9-BF76-B0B8EDE6B578}"
+ ProjectSection(SolutionItems) = preProject
+ docs\index.md = docs\index.md
+ mkdocs.yml = mkdocs.yml
+ requirements.txt = requirements.txt
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "assets", "assets", "{064CF7F9-2FC5-45EF-9718-346EC81E819A}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "css", "css", "{72A05CD4-4462-4402-A972-49C30910EC82}"
+ ProjectSection(SolutionItems) = preProject
+ docs\assets\css\style.scss = docs\assets\css\style.scss
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{F52AB59C-12E2-4563-B811-888F9CFE884B}"
+ ProjectSection(SolutionItems) = preProject
+ docs\examples\index.md = docs\examples\index.md
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "middleware", "middleware", "{CEFF8EF9-B530-472C-A963-A953728322BB}"
+ ProjectSection(SolutionItems) = preProject
+ docs\middleware\lambda-timeout-middleware.md = docs\middleware\lambda-timeout-middleware.md
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests", "test\LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests\LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.csproj", "{EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Debug|x64.Build.0 = Debug|Any CPU
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Debug|x86.Build.0 = Debug|Any CPU
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Release|Any CPU.Build.0 = Release|Any CPU
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Release|x64.ActiveCfg = Release|Any CPU
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Release|x64.Build.0 = Release|Any CPU
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Release|x86.ActiveCfg = Release|Any CPU
+ {789D3B32-3F92-4628-8416-08F3927E1B57}.Release|x86.Build.0 = Release|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Debug|x64.Build.0 = Debug|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Debug|x86.Build.0 = Debug|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Release|x64.ActiveCfg = Release|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Release|x64.Build.0 = Release|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Release|x86.ActiveCfg = Release|Any CPU
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {789D3B32-3F92-4628-8416-08F3927E1B57} = {97C05F93-574D-41AA-A848-C4B7731FACC0}
+ {064CF7F9-2FC5-45EF-9718-346EC81E819A} = {93154236-6904-4ED9-BF76-B0B8EDE6B578}
+ {72A05CD4-4462-4402-A972-49C30910EC82} = {064CF7F9-2FC5-45EF-9718-346EC81E819A}
+ {F52AB59C-12E2-4563-B811-888F9CFE884B} = {93154236-6904-4ED9-BF76-B0B8EDE6B578}
+ {CEFF8EF9-B530-472C-A963-A953728322BB} = {93154236-6904-4ED9-BF76-B0B8EDE6B578}
+ {EB5EF2DF-D345-4ACA-8017-DDBE8A056F2C} = {C52DAB93-ADC4-423B-9820-C705A55436A3}
+ EndGlobalSection
+EndGlobal
diff --git a/README.md b/README.md
index 25004dd..4391aa5 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,180 @@
-# lambda-aspnetcore-hosting-extensions
-Extensions and middleware for Amazon.Lambda.AspNetCoreServer.Hosting . Provides ASP.NET Core components designed specifically for AWS Lambda hosting, delivering improved reliability, observability, and developer experience.
+# LayeredCraft Lambda ASP.NET Core Hosting Extensions
+
+[](https://github.com/LayeredCraft/lambda-aspnetcore-hosting-extensions/actions/workflows/build.yaml)
+[](https://www.nuget.org/packages/LayeredCraft.Lambda.AspNetCore.HostingExtensions/)
+[](https://www.nuget.org/packages/LayeredCraft.Lambda.AspNetCore.HostingExtensions/)
+
+Extensions and middleware for Amazon.Lambda.AspNetCoreServer.Hosting. Provides ASP.NET Core components designed specifically for AWS Lambda hosting, delivering improved reliability, observability, and developer experience.
+
+## Features
+
+- **⏱️ Lambda Timeout Handling**: Intelligent timeout middleware that links Lambda execution limits with HTTP request cancellation
+- **🔄 Graceful Shutdown**: Proper handling of approaching Lambda timeouts with configurable safety buffers
+- **🛠️ Developer Experience**: Standard CancellationToken patterns work seamlessly in Lambda environments
+- **🧪 Local Development**: Pass-through behavior when running outside Lambda (Kestrel, IIS Express)
+- **📊 Observability**: Structured logging with detailed timeout and cancellation telemetry
+
+## Installation
+
+```bash
+dotnet add package LayeredCraft.Lambda.AspNetCore.HostingExtensions
+```
+
+## Quick Start
+
+### Basic Timeout Middleware
+
+```csharp
+using LayeredCraft.Lambda.AspNetCore.Hosting.Extensions;
+
+public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+{
+ // Add Lambda timeout-aware cancellation early in the pipeline
+ app.UseLambdaTimeoutLinkedCancellation();
+
+ // Your other middleware
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ });
+}
+```
+
+### With Custom Safety Buffer
+
+```csharp
+public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+{
+ // Custom safety buffer of 500ms for cleanup operations
+ app.UseLambdaTimeoutLinkedCancellation(TimeSpan.FromMilliseconds(500));
+
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ });
+}
+```
+
+### Using Cancellation in Controllers
+
+```csharp
+[ApiController]
+[Route("api/[controller]")]
+public class DataController : ControllerBase
+{
+ private readonly IDataService _dataService;
+
+ public DataController(IDataService dataService)
+ {
+ _dataService = dataService;
+ }
+
+ [HttpGet]
+ public async Task GetData(CancellationToken cancellationToken)
+ {
+ try
+ {
+ // This will be cancelled if Lambda timeout approaches
+ var data = await _dataService.GetDataAsync(cancellationToken);
+ return Ok(data);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Lambda timeout occurred - return appropriate response
+ return StatusCode(504, "Request timeout");
+ }
+ }
+}
+```
+
+## Documentation
+
+📖 **[Complete Documentation](https://layeredcraft.github.io/lambda-aspnetcore-hosting-extensions/)**
+
+- **[Lambda Timeout Middleware](https://layeredcraft.github.io/lambda-aspnetcore-hosting-extensions/middleware/lambda-timeout-middleware)** - Comprehensive timeout handling for Lambda environments
+- **[Examples](https://layeredcraft.github.io/lambda-aspnetcore-hosting-extensions/examples)** - Real-world usage examples and patterns
+
+## How It Works
+
+### Lambda Timeout Linking
+
+The `LambdaTimeoutLinkMiddleware` creates a sophisticated cancellation token that triggers when either:
+
+1. **Client disconnects** or the server aborts the request (standard ASP.NET Core behavior)
+2. **Lambda timeout approaches** (calculated from `ILambdaContext.RemainingTime` with safety buffer)
+
+The middleware replaces `HttpContext.RequestAborted` with this linked token, enabling downstream code to respond to Lambda timeouts through standard `CancellationToken` patterns.
+
+### Safety Buffer
+
+The configurable safety buffer (default: 250ms) ensures your application has time to:
+- Complete cleanup operations
+- Write final log entries
+- Return appropriate HTTP status codes
+- Flush telemetry data
+
+### Local Development
+
+When running locally (Kestrel, IIS Express) where `ILambdaContext` is unavailable, the middleware operates as a pass-through with only standard client disconnect cancellation active.
+
+## Status Codes
+
+The middleware automatically sets appropriate HTTP status codes on timeout:
+
+- **504 Gateway Timeout**: Lambda execution timeout occurred
+- **499 Client Closed Request**: Client disconnected (non-standard but widely recognized)
+
+## Requirements
+
+- **.NET 8.0** or **.NET 9.0**
+- **Amazon.Lambda.AspNetCoreServer** 9.2.0+
+- **LayeredCraft.StructuredLogging** 1.1.1.8+
+
+## Contributing
+
+We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details.
+
+### Development Setup
+
+```bash
+# Clone the repository
+git clone https://github.com/LayeredCraft/lambda-aspnetcore-hosting-extensions.git
+cd lambda-aspnetcore-hosting-extensions
+
+# Restore dependencies
+dotnet restore
+
+# Build the project
+dotnet build
+
+# Run tests
+dotnet test
+```
+
+### Code Style
+
+- Follow C# coding conventions
+- Use meaningful names for variables and methods
+- Add XML documentation for public APIs
+- Include unit tests for new features
+- Run tests before submitting PRs
+
+## License
+
+This project is licensed under the [MIT License](LICENSE).
+
+## Support
+
+- **Issues**: [GitHub Issues](https://github.com/LayeredCraft/lambda-aspnetcore-hosting-extensions/issues)
+- **Discussions**: [GitHub Discussions](https://github.com/LayeredCraft/lambda-aspnetcore-hosting-extensions/discussions)
+- **Documentation**: [https://layeredcraft.github.io/lambda-aspnetcore-hosting-extensions/](https://layeredcraft.github.io/lambda-aspnetcore-hosting-extensions/)
+
+## Changelog
+
+See [CHANGELOG.md](CHANGELOG.md) for details on releases and changes.
+
+---
+
+Built with ❤️ by the LayeredCraft team
diff --git a/docs/assets/css/style.scss b/docs/assets/css/style.scss
new file mode 100644
index 0000000..66eb863
--- /dev/null
+++ b/docs/assets/css/style.scss
@@ -0,0 +1,166 @@
+---
+---
+
+@import "{{ site.theme }}";
+
+.main-header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 2rem 0;
+ margin-bottom: 2rem;
+
+ h1 {
+ margin: 0;
+ font-size: 2.5rem;
+ font-weight: 300;
+ }
+
+ .tagline {
+ margin-top: 0.5rem;
+ font-size: 1.2rem;
+ opacity: 0.9;
+ }
+}
+
+.features-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 2rem;
+ margin: 2rem 0;
+}
+
+.feature-card {
+ background: #f8f9fa;
+ border: 1px solid #e9ecef;
+ border-radius: 8px;
+ padding: 1.5rem;
+
+ h3 {
+ color: #495057;
+ margin-top: 0;
+ }
+
+ .emoji {
+ font-size: 2rem;
+ display: block;
+ margin-bottom: 1rem;
+ }
+}
+
+.badge-container {
+ margin: 1rem 0;
+
+ img {
+ margin-right: 0.5rem;
+ margin-bottom: 0.5rem;
+ }
+}
+
+.code-example {
+ background: #f8f9fa;
+ border: 1px solid #e9ecef;
+ border-radius: 6px;
+ padding: 1rem;
+ margin: 1rem 0;
+
+ pre {
+ margin: 0;
+ background: none;
+ border: none;
+ padding: 0;
+ }
+}
+
+.warning-box {
+ background: #fff3cd;
+ border: 1px solid #ffeaa7;
+ border-left: 4px solid #f39c12;
+ border-radius: 4px;
+ padding: 1rem;
+ margin: 1rem 0;
+
+ .warning-title {
+ font-weight: bold;
+ color: #856404;
+ margin-bottom: 0.5rem;
+ }
+}
+
+.info-box {
+ background: #d1ecf1;
+ border: 1px solid #bee5eb;
+ border-left: 4px solid #17a2b8;
+ border-radius: 4px;
+ padding: 1rem;
+ margin: 1rem 0;
+
+ .info-title {
+ font-weight: bold;
+ color: #0c5460;
+ margin-bottom: 0.5rem;
+ }
+}
+
+.success-box {
+ background: #d4edda;
+ border: 1px solid #c3e6cb;
+ border-left: 4px solid #28a745;
+ border-radius: 4px;
+ padding: 1rem;
+ margin: 1rem 0;
+
+ .success-title {
+ font-weight: bold;
+ color: #155724;
+ margin-bottom: 0.5rem;
+ }
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1rem 0;
+
+ th, td {
+ text-align: left;
+ padding: 0.75rem;
+ border-bottom: 1px solid #dee2e6;
+ }
+
+ th {
+ background-color: #f8f9fa;
+ font-weight: 600;
+ color: #495057;
+ }
+
+ tbody tr:hover {
+ background-color: #f8f9fa;
+ }
+}
+
+.nav-links {
+ margin: 2rem 0;
+
+ a {
+ display: inline-block;
+ background: #007bff;
+ color: white;
+ padding: 0.5rem 1rem;
+ text-decoration: none;
+ border-radius: 4px;
+ margin-right: 1rem;
+ margin-bottom: 0.5rem;
+
+ &:hover {
+ background: #0056b3;
+ }
+ }
+}
+
+.footer {
+ margin-top: 3rem;
+ padding-top: 2rem;
+ border-top: 1px solid #e9ecef;
+ text-align: center;
+ color: #6c757d;
+}
\ No newline at end of file
diff --git a/docs/examples/index.md b/docs/examples/index.md
new file mode 100644
index 0000000..da060c2
--- /dev/null
+++ b/docs/examples/index.md
@@ -0,0 +1,690 @@
+# Examples
+
+Real-world usage examples and patterns for LayeredCraft Lambda ASP.NET Core Hosting Extensions.
+
+## Basic API with Timeout Handling
+
+A simple REST API that handles Lambda timeouts gracefully.
+
+### Startup Configuration
+
+```csharp
+using LayeredCraft.Lambda.AspNetCore.Hosting.Extensions;
+
+public class Startup
+{
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddControllers();
+ services.AddHttpClient();
+ services.AddScoped();
+
+ // Configure logging
+ services.AddLogging(builder =>
+ {
+ builder.AddConsole();
+ builder.AddAWSProvider();
+ });
+ }
+
+ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ if (env.IsDevelopment())
+ {
+ app.UseDeveloperExceptionPage();
+ }
+
+ // Add Lambda timeout middleware early in pipeline
+ app.UseLambdaTimeoutLinkedCancellation();
+
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ });
+ }
+}
+```
+
+### Controller Implementation
+
+```csharp
+[ApiController]
+[Route("api/[controller]")]
+public class UsersController : ControllerBase
+{
+ private readonly IDataRepository _repository;
+ private readonly IExternalApiService _externalApi;
+ private readonly ILogger _logger;
+
+ public UsersController(
+ IDataRepository repository,
+ IExternalApiService externalApi,
+ ILogger logger)
+ {
+ _repository = repository;
+ _externalApi = externalApi;
+ _logger = logger;
+ }
+
+ [HttpGet("{id}")]
+ public async Task GetUser(string id, CancellationToken cancellationToken)
+ {
+ try
+ {
+ _logger.LogInformation("Fetching user {UserId}", id);
+
+ // Repository call respects cancellation
+ var user = await _repository.GetUserAsync(id, cancellationToken);
+ if (user == null)
+ return NotFound();
+
+ // Enrich with external data if time permits
+ try
+ {
+ user.ExternalData = await _externalApi.GetUserEnrichmentAsync(id, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogWarning("External enrichment cancelled for user {UserId}", id);
+ // Continue without external data
+ }
+
+ return Ok(user);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ _logger.LogWarning("User fetch cancelled due to timeout for {UserId}", id);
+ return StatusCode(504, "Request timed out");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching user {UserId}", id);
+ return StatusCode(500, "Internal server error");
+ }
+ }
+
+ [HttpPost]
+ public async Task CreateUser([FromBody] CreateUserRequest request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ _logger.LogInformation("Creating user {Email}", request.Email);
+
+ // Validate request
+ if (!ModelState.IsValid)
+ return BadRequest(ModelState);
+
+ // Check for existing user
+ var existing = await _repository.GetUserByEmailAsync(request.Email, cancellationToken);
+ if (existing != null)
+ return Conflict("User already exists");
+
+ // Create user with timeout awareness
+ var user = await _repository.CreateUserAsync(request, cancellationToken);
+
+ // Send welcome email (fire-and-forget with timeout)
+ _ = Task.Run(async () =>
+ {
+ using var emailCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ try
+ {
+ await _externalApi.SendWelcomeEmailAsync(user.Email, emailCts.Token);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to send welcome email to {Email}", user.Email);
+ }
+ }, CancellationToken.None);
+
+ return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ _logger.LogWarning("User creation cancelled due to timeout for {Email}", request.Email);
+ return StatusCode(504, "Request timed out");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error creating user {Email}", request.Email);
+ return StatusCode(500, "Internal server error");
+ }
+ }
+}
+```
+
+## Data Processing API with Batch Operations
+
+An API that processes large datasets with timeout awareness.
+
+### Service Implementation
+
+```csharp
+public interface IDataProcessingService
+{
+ Task ProcessDataAsync(ProcessingRequest request, CancellationToken cancellationToken);
+ Task ProcessBatchAsync(IEnumerable items, CancellationToken cancellationToken);
+}
+
+public class DataProcessingService : IDataProcessingService
+{
+ private readonly ILogger _logger;
+ private readonly IExternalService _externalService;
+
+ public DataProcessingService(ILogger logger, IExternalService externalService)
+ {
+ _logger = logger;
+ _externalService = externalService;
+ }
+
+ public async Task ProcessDataAsync(ProcessingRequest request,
+ CancellationToken cancellationToken)
+ {
+ var result = new ProcessingResult { StartTime = DateTime.UtcNow };
+
+ try
+ {
+ _logger.LogInformation("Starting data processing for {RequestId}", request.Id);
+
+ // Step 1: Validate data (quick operation)
+ var validationResult = await ValidateDataAsync(request.Data, cancellationToken);
+ if (!validationResult.IsValid)
+ {
+ result.Errors.AddRange(validationResult.Errors);
+ return result;
+ }
+
+ // Step 2: Process in batches with timeout checks
+ var batches = request.Data.Chunk(100); // Process 100 items at a time
+ var processedCount = 0;
+
+ foreach (var batch in batches)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var batchResult = await ProcessBatchAsync(batch, cancellationToken);
+ result.ProcessedItems += batchResult.ProcessedCount;
+ result.Errors.AddRange(batchResult.Errors);
+
+ processedCount += batch.Length;
+ _logger.LogDebug("Processed {Count}/{Total} items",
+ processedCount, request.Data.Length);
+ }
+
+ // Step 3: Finalize processing
+ await FinalizeProcessingAsync(result, cancellationToken);
+
+ result.IsSuccess = result.Errors.Count == 0;
+ result.EndTime = DateTime.UtcNow;
+
+ _logger.LogInformation("Completed data processing for {RequestId}. " +
+ "Processed: {Processed}, Errors: {Errors}, Duration: {Duration}ms",
+ request.Id, result.ProcessedItems, result.Errors.Count,
+ (result.EndTime - result.StartTime).TotalMilliseconds);
+
+ return result;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ result.EndTime = DateTime.UtcNow;
+ result.IsCancelled = true;
+
+ _logger.LogWarning("Data processing cancelled for {RequestId} after {Duration}ms. " +
+ "Processed: {Processed} items",
+ request.Id,
+ (result.EndTime - result.StartTime).TotalMilliseconds,
+ result.ProcessedItems);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ result.EndTime = DateTime.UtcNow;
+ result.IsSuccess = false;
+ result.Errors.Add($"Processing failed: {ex.Message}");
+
+ _logger.LogError(ex, "Data processing failed for {RequestId}", request.Id);
+ throw;
+ }
+ }
+
+ public async Task ProcessBatchAsync(IEnumerable items,
+ CancellationToken cancellationToken)
+ {
+ var result = new BatchResult();
+ var tasks = new List>();
+
+ // Process items concurrently with timeout awareness
+ foreach (var item in items)
+ {
+ tasks.Add(ProcessSingleItemAsync(item, cancellationToken));
+
+ // Limit concurrent operations to prevent resource exhaustion
+ if (tasks.Count >= 10)
+ {
+ var completed = await Task.WhenAny(tasks);
+ var itemResult = await completed;
+
+ tasks.Remove(completed);
+ UpdateBatchResult(result, itemResult);
+ }
+ }
+
+ // Process remaining items
+ while (tasks.Count > 0)
+ {
+ var completed = await Task.WhenAny(tasks);
+ var itemResult = await completed;
+
+ tasks.Remove(completed);
+ UpdateBatchResult(result, itemResult);
+ }
+
+ return result;
+ }
+
+ private async Task ProcessSingleItemAsync(DataItem item, CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Simulate processing with external API call
+ var processedData = await _externalService.ProcessItemAsync(item, cancellationToken);
+
+ return new ItemResult
+ {
+ Id = item.Id,
+ IsSuccess = true,
+ ProcessedData = processedData
+ };
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw; // Propagate cancellation
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to process item {ItemId}", item.Id);
+
+ return new ItemResult
+ {
+ Id = item.Id,
+ IsSuccess = false,
+ Error = ex.Message
+ };
+ }
+ }
+}
+```
+
+### Controller with Timeout Handling
+
+```csharp
+[ApiController]
+[Route("api/[controller]")]
+public class ProcessingController : ControllerBase
+{
+ private readonly IDataProcessingService _processingService;
+ private readonly ILogger _logger;
+
+ public ProcessingController(IDataProcessingService processingService,
+ ILogger logger)
+ {
+ _processingService = processingService;
+ _logger = logger;
+ }
+
+ [HttpPost("process")]
+ public async Task ProcessData([FromBody] ProcessingRequest request,
+ CancellationToken cancellationToken)
+ {
+ if (request?.Data == null || !request.Data.Any())
+ {
+ return BadRequest("No data provided for processing");
+ }
+
+ try
+ {
+ var result = await _processingService.ProcessDataAsync(request, cancellationToken);
+
+ if (result.IsSuccess)
+ {
+ return Ok(new
+ {
+ message = "Processing completed successfully",
+ processedItems = result.ProcessedItems,
+ duration = (result.EndTime - result.StartTime).TotalMilliseconds
+ });
+ }
+ else
+ {
+ return BadRequest(new
+ {
+ message = "Processing completed with errors",
+ processedItems = result.ProcessedItems,
+ errors = result.Errors,
+ duration = (result.EndTime - result.StartTime).TotalMilliseconds
+ });
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ _logger.LogWarning("Processing request cancelled for {RequestId}", request.Id);
+ return StatusCode(504, new
+ {
+ message = "Processing was cancelled due to timeout",
+ requestId = request.Id
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unexpected error processing request {RequestId}", request.Id);
+ return StatusCode(500, "An unexpected error occurred");
+ }
+ }
+}
+```
+
+## File Upload with Progress Tracking
+
+Handling file uploads with timeout awareness and progress tracking.
+
+### File Upload Controller
+
+```csharp
+[ApiController]
+[Route("api/[controller]")]
+public class FileController : ControllerBase
+{
+ private readonly IFileProcessingService _fileService;
+ private readonly ILogger _logger;
+
+ public FileController(IFileProcessingService fileService, ILogger logger)
+ {
+ _fileService = fileService;
+ _logger = logger;
+ }
+
+ [HttpPost("upload")]
+ public async Task UploadFile(IFormFile file, CancellationToken cancellationToken)
+ {
+ if (file == null || file.Length == 0)
+ {
+ return BadRequest("No file provided");
+ }
+
+ var uploadId = Guid.NewGuid().ToString();
+
+ try
+ {
+ _logger.LogInformation("Starting file upload {UploadId}. File: {FileName}, Size: {Size} bytes",
+ uploadId, file.FileName, file.Length);
+
+ using var stream = file.OpenReadStream();
+ var result = await _fileService.ProcessFileAsync(
+ uploadId,
+ file.FileName,
+ stream,
+ cancellationToken);
+
+ return Ok(new
+ {
+ uploadId = uploadId,
+ fileName = file.FileName,
+ size = file.Length,
+ processedRecords = result.ProcessedRecords,
+ duration = result.ProcessingTime.TotalMilliseconds
+ });
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ _logger.LogWarning("File upload cancelled {UploadId}", uploadId);
+ return StatusCode(504, new
+ {
+ message = "File upload was cancelled due to timeout",
+ uploadId = uploadId
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "File upload failed {UploadId}", uploadId);
+ return StatusCode(500, "File upload failed");
+ }
+ }
+}
+```
+
+### File Processing Service
+
+```csharp
+public class FileProcessingService : IFileProcessingService
+{
+ private readonly ILogger _logger;
+
+ public FileProcessingService(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public async Task ProcessFileAsync(string uploadId,
+ string fileName,
+ Stream fileStream,
+ CancellationToken cancellationToken)
+ {
+ var result = new FileProcessingResult { StartTime = DateTime.UtcNow };
+
+ try
+ {
+ using var reader = new StreamReader(fileStream);
+ var recordCount = 0;
+ var processedCount = 0;
+
+ string line;
+ while ((line = await reader.ReadLineAsync()) != null)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ recordCount++;
+
+ // Process each line/record
+ if (await ProcessRecordAsync(line, cancellationToken))
+ {
+ processedCount++;
+ }
+
+ // Log progress periodically
+ if (recordCount % 1000 == 0)
+ {
+ _logger.LogDebug("Processing file {UploadId}: {Processed}/{Total} records",
+ uploadId, processedCount, recordCount);
+ }
+ }
+
+ result.ProcessedRecords = processedCount;
+ result.TotalRecords = recordCount;
+ result.EndTime = DateTime.UtcNow;
+ result.ProcessingTime = result.EndTime - result.StartTime;
+
+ _logger.LogInformation("Completed file processing {UploadId}. " +
+ "Processed: {Processed}/{Total} records in {Duration}ms",
+ uploadId, processedCount, recordCount,
+ result.ProcessingTime.TotalMilliseconds);
+
+ return result;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ result.EndTime = DateTime.UtcNow;
+ result.ProcessingTime = result.EndTime - result.StartTime;
+
+ _logger.LogWarning("File processing cancelled {UploadId} after {Duration}ms. " +
+ "Processed: {Processed} records",
+ uploadId, result.ProcessingTime.TotalMilliseconds, result.ProcessedRecords);
+ throw;
+ }
+ }
+
+ private async Task ProcessRecordAsync(string record, CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Simulate record processing
+ await Task.Delay(1, cancellationToken); // Minimal delay for cancellation checks
+
+ // Actual processing logic here
+ return !string.IsNullOrWhiteSpace(record);
+ }
+ catch (OperationCanceledException)
+ {
+ throw; // Propagate cancellation
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to process record: {Record}", record);
+ return false;
+ }
+ }
+}
+```
+
+## Custom Safety Buffer Configuration
+
+Different scenarios requiring different safety buffer configurations.
+
+### Environment-Based Configuration
+
+```csharp
+public class Startup
+{
+ private readonly IConfiguration _configuration;
+
+ public Startup(IConfiguration configuration)
+ {
+ _configuration = configuration;
+ }
+
+ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ // Configure safety buffer based on environment
+ var safetyBufferMs = _configuration.GetValue("Lambda:SafetyBufferMs", 250);
+ var safetyBuffer = TimeSpan.FromMilliseconds(safetyBufferMs);
+
+ app.UseLambdaTimeoutLinkedCancellation(safetyBuffer);
+
+ // Other middleware...
+ }
+}
+```
+
+### Operation-Specific Buffers
+
+```csharp
+public class CustomTimeoutController : ControllerBase
+{
+ [HttpPost("quick-operation")]
+ public async Task QuickOperation(CancellationToken cancellationToken)
+ {
+ // For quick operations, use a shorter timeout to maximize processing time
+ using var shortCts = new CancellationTokenSource(TimeSpan.FromSeconds(25));
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken, shortCts.Token);
+
+ try
+ {
+ var result = await ProcessQuicklyAsync(linkedCts.Token);
+ return Ok(result);
+ }
+ catch (OperationCanceledException)
+ {
+ return StatusCode(504, "Operation timed out");
+ }
+ }
+
+ [HttpPost("heavy-operation")]
+ public async Task HeavyOperation(CancellationToken cancellationToken)
+ {
+ // For heavy operations, respect the middleware's timeout
+ try
+ {
+ var result = await ProcessHeavilyAsync(cancellationToken);
+ return Ok(result);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return StatusCode(504, "Operation timed out");
+ }
+ }
+}
+```
+
+## Testing with Timeout Scenarios
+
+Unit tests for timeout handling.
+
+### Test Setup
+
+```csharp
+[TestFixture]
+public class TimeoutHandlingTests
+{
+ private TestServer _server;
+ private HttpClient _client;
+
+ [SetUp]
+ public void Setup()
+ {
+ var builder = new WebHostBuilder()
+ .UseStartup()
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton();
+ });
+
+ _server = new TestServer(builder);
+ _client = _server.CreateClient();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _client?.Dispose();
+ _server?.Dispose();
+ }
+
+ [Test]
+ public async Task Should_Handle_Timeout_Gracefully()
+ {
+ // Arrange
+ var request = new ProcessingRequest
+ {
+ Id = "test-123",
+ Data = Enumerable.Range(1, 1000).Select(i => new DataItem { Id = i.ToString() }).ToArray()
+ };
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
+
+ // Act & Assert
+ var response = await _client.PostAsJsonAsync("/api/processing/process", request, cts.Token);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.GatewayTimeout));
+ }
+
+ [Test]
+ public async Task Should_Complete_Quick_Operations()
+ {
+ // Arrange
+ var request = new ProcessingRequest
+ {
+ Id = "test-456",
+ Data = new[] { new DataItem { Id = "1" } }
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/processing/process", request);
+
+ // Assert
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ }
+}
+```
+
+These examples demonstrate comprehensive patterns for using the Lambda timeout middleware in real-world scenarios, showing how to handle various timeout situations gracefully while maintaining good observability and user experience.
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..5ffe4b7
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,84 @@
+---
+layout: default
+title: LayeredCraft Lambda ASP.NET Core Hosting Extensions
+---
+
+[](https://github.com/LayeredCraft/lambda-aspnetcore-hosting-extensions/actions/workflows/build.yaml)
+[](https://www.nuget.org/packages/LayeredCraft.Lambda.AspNetCore.HostingExtensions/)
+[](https://www.nuget.org/packages/LayeredCraft.Lambda.AspNetCore.HostingExtensions/)
+
+Extensions and middleware for Amazon.Lambda.AspNetCoreServer.Hosting. Provides ASP.NET Core components designed specifically for AWS Lambda hosting, delivering improved reliability, observability, and developer experience.
+
+## Key Features
+
+- **⏱️ Lambda Timeout Handling**: Intelligent timeout middleware that links Lambda execution limits with HTTP request cancellation
+- **🔄 Graceful Shutdown**: Proper handling of approaching Lambda timeouts with configurable safety buffers
+- **🛠️ Developer Experience**: Standard CancellationToken patterns work seamlessly in Lambda environments
+- **🧪 Local Development**: Pass-through behavior when running outside Lambda (Kestrel, IIS Express)
+- **📊 Observability**: Structured logging with detailed timeout and cancellation telemetry
+
+## Installation
+
+```bash
+dotnet add package LayeredCraft.Lambda.AspNetCore.HostingExtensions
+```
+
+## Quick Start
+
+```csharp
+using LayeredCraft.Lambda.AspNetCore.Hosting.Extensions;
+
+public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+{
+ // Add Lambda timeout-aware cancellation early in the pipeline
+ app.UseLambdaTimeoutLinkedCancellation();
+
+ // Your other middleware
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ });
+}
+```
+
+## Available Middleware
+
+### [Lambda Timeout Middleware](middleware/lambda-timeout-middleware.md)
+
+Comprehensive timeout handling for Lambda environments with:
+
+- Linked cancellation tokens (client disconnect + Lambda timeout)
+- Configurable safety buffers for graceful shutdown
+- Appropriate HTTP status code handling (504/499)
+- Structured logging for observability
+- Local development pass-through mode
+
+## Documentation
+
+- **[Examples](examples/index.md)** - Real-world usage examples and patterns
+
+## How It Works
+
+The library provides middleware that integrates with AWS Lambda's execution model to handle timeouts gracefully. When Lambda execution approaches its timeout limit, the middleware triggers cancellation tokens that downstream code can respond to using standard .NET patterns.
+
+### Key Benefits
+
+1. **Prevents Lambda Cold Timeouts**: Graceful shutdown before Lambda forcibly terminates
+2. **Standard Patterns**: Use familiar `CancellationToken` APIs everywhere
+3. **Observability**: Detailed logging helps diagnose timeout issues
+4. **Development Friendly**: Works seamlessly in local and Lambda environments
+
+## Requirements
+
+- **.NET 8.0** or **.NET 9.0**
+- **Amazon.Lambda.AspNetCoreServer** 9.2.0+
+- **LayeredCraft.StructuredLogging** 1.1.1.8+
+
+## Contributing
+
+See the main [README](https://github.com/LayeredCraft/lambda-aspnetcore-hosting-extensions#contributing) for contribution guidelines.
+
+## License
+
+This project is licensed under the [MIT License](https://github.com/LayeredCraft/lambda-aspnetcore-hosting-extensions/blob/main/LICENSE).
\ No newline at end of file
diff --git a/docs/middleware/lambda-timeout-middleware.md b/docs/middleware/lambda-timeout-middleware.md
new file mode 100644
index 0000000..1f23fbc
--- /dev/null
+++ b/docs/middleware/lambda-timeout-middleware.md
@@ -0,0 +1,466 @@
+# Lambda Timeout Middleware
+
+The `LambdaTimeoutLinkMiddleware` provides intelligent timeout handling for ASP.NET Core applications running in AWS Lambda environments. It creates a sophisticated cancellation system that responds to both client disconnections and Lambda execution timeouts.
+
+## :rocket: Features
+
+- **:stopwatch: Dual Cancellation**: Responds to both client disconnect and Lambda timeout scenarios
+- **:shield: Graceful Shutdown**: Configurable safety buffer ensures clean application shutdown
+- **:chart_with_upwards_trend: Observability**: Structured logging with detailed timeout telemetry
+- **:computer: Local Development**: Pass-through mode when `ILambdaContext` is unavailable
+- **:warning: Status Code Management**: Automatic HTTP status code setting (504/499)
+
+## Basic Usage
+
+### Simple Configuration
+
+```csharp
+using LayeredCraft.Lambda.AspNetCore.Hosting.Extensions;
+
+public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+{
+ // Add early in pipeline for maximum coverage
+ app.UseLambdaTimeoutLinkedCancellation();
+
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ });
+}
+```
+
+### Custom Safety Buffer
+
+```csharp
+public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+{
+ // Allow 500ms for cleanup operations
+ app.UseLambdaTimeoutLinkedCancellation(TimeSpan.FromMilliseconds(500));
+
+ // Other middleware...
+}
+```
+
+## Configuration Options
+
+### Safety Buffer
+
+The safety buffer determines how much time before Lambda timeout the middleware should trigger cancellation:
+
+| Buffer Time | Use Case | Trade-offs |
+|-------------|----------|------------|
+| `100ms` | Fast response, minimal cleanup | Risk of incomplete operations |
+| `250ms` (default) | Balanced approach | Good for most applications |
+| `500ms` | Heavy cleanup operations | Reduces effective Lambda runtime |
+| `1000ms+` | Database transactions, file I/O | Significant runtime reduction |
+
+### Safety Buffer Guidelines
+
+```csharp
+// For APIs with minimal cleanup
+app.UseLambdaTimeoutLinkedCancellation(TimeSpan.FromMilliseconds(100));
+
+// For applications with moderate cleanup needs (default)
+app.UseLambdaTimeoutLinkedCancellation(); // 250ms
+
+// For applications with heavy cleanup (database, files, etc.)
+app.UseLambdaTimeoutLinkedCancellation(TimeSpan.FromMilliseconds(500));
+
+// For applications with very heavy cleanup operations
+app.UseLambdaTimeoutLinkedCancellation(TimeSpan.FromSeconds(1));
+```
+
+## Using Cancellation Tokens
+
+### In Controllers
+
+```csharp
+[ApiController]
+[Route("api/[controller]")]
+public class DataController : ControllerBase
+{
+ private readonly IDataRepository _repository;
+
+ public DataController(IDataRepository repository)
+ {
+ _repository = repository;
+ }
+
+ [HttpGet]
+ public async Task GetData(CancellationToken cancellationToken)
+ {
+ try
+ {
+ // This operation will be cancelled on Lambda timeout
+ var data = await _repository.GetDataAsync(cancellationToken);
+ return Ok(data);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Log timeout for debugging
+ return StatusCode(504, "Request timed out");
+ }
+ }
+
+ [HttpPost]
+ public async Task CreateData([FromBody] CreateDataRequest request,
+ CancellationToken cancellationToken)
+ {
+ using var operation = _logger.BeginScope("CreateData Operation");
+
+ try
+ {
+ // Multi-step operation with timeout awareness
+ var validationResult = await ValidateRequestAsync(request, cancellationToken);
+ if (!validationResult.IsValid)
+ return BadRequest(validationResult.Errors);
+
+ var data = await _repository.CreateAsync(request, cancellationToken);
+ await _eventPublisher.PublishCreatedAsync(data, cancellationToken);
+
+ return CreatedAtAction(nameof(GetData), new { id = data.Id }, data);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ _logger.LogWarning("CreateData operation cancelled due to timeout");
+ return StatusCode(504, "Operation timed out");
+ }
+ }
+}
+```
+
+### In Services
+
+```csharp
+public class DataService : IDataService
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ public DataService(HttpClient httpClient, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ public async Task GetExternalDataAsync(string id, CancellationToken cancellationToken)
+ {
+ try
+ {
+ // External HTTP calls respect the cancellation token
+ var response = await _httpClient.GetAsync($"/api/data/{id}", cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsStringAsync(cancellationToken);
+ return JsonSerializer.Deserialize(content);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ _logger.LogWarning("External API call cancelled due to timeout for ID: {Id}", id);
+ throw; // Re-throw to propagate cancellation
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogError(ex, "Failed to fetch external data for ID: {Id}", id);
+ throw;
+ }
+ }
+
+ public async Task ProcessLargeDatasetAsync(IEnumerable items,
+ CancellationToken cancellationToken)
+ {
+ var processedCount = 0;
+
+ await foreach (var item in items.WithCancellation(cancellationToken))
+ {
+ await ProcessSingleItemAsync(item, cancellationToken);
+ processedCount++;
+
+ // Periodic cancellation checks for long-running operations
+ if (processedCount % 100 == 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ _logger.LogDebug("Processed {Count} items", processedCount);
+ }
+ }
+ }
+}
+```
+
+## How It Works
+
+### Cancellation Token Linking
+
+The middleware creates a linked cancellation token from two sources:
+
+```mermaid
+graph TD
+ A[HttpContext.RequestAborted] --> C[Linked Token]
+ B[Lambda Timeout Token] --> C
+ C --> D[Downstream Middleware]
+```
+
+1. **Original Token**: `HttpContext.RequestAborted` (client disconnect, server abort)
+2. **Timeout Token**: Created from `ILambdaContext.RemainingTime - SafetyBuffer`
+3. **Linked Token**: Cancels when either source cancels
+
+### Timeline Example
+
+```
+Lambda Timeout: 30 seconds
+Safety Buffer: 250ms
+Effective Timeout: 29.75 seconds
+
+0s 29.75s 30s
+|---------------------|---------|
+| Application |Cleanup |
+| Processing | Period |
+```
+
+### Local Development Behavior
+
+When `ILambdaContext` is not available (local development):
+
+```csharp
+// Creates a "never timeout" token (24 hours)
+using var timeoutCts = new CancellationTokenSource(TimeSpan.FromDays(1));
+
+// Only client disconnect triggers cancellation
+using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
+ original, // HttpContext.RequestAborted
+ timeoutCts.Token // Never fires locally
+);
+```
+
+## HTTP Status Codes
+
+The middleware automatically sets appropriate status codes:
+
+| Scenario | Status Code | Description |
+|----------|-------------|-------------|
+| Lambda timeout | `504 Gateway Timeout` | Standard timeout response |
+| Client disconnect | `499 Client Closed Request` | Non-standard but widely recognized |
+
+### Status Code Handling
+
+```csharp
+// In the middleware's exception handler
+var byTimeout = timeoutCts.IsCancellationRequested;
+
+context.Response.StatusCode = byTimeout
+ ? StatusCodes.Status504GatewayTimeout // 504
+ : ClientClosedRequest; // 499
+```
+
+## Logging and Observability
+
+### Structured Logging
+
+The middleware uses `LayeredCraft.StructuredLogging` for rich telemetry:
+
+```csharp
+_logger.Warning(
+ "Request cancelled ({Reason}). Path: {Path}, RemainingTimeMs: {RemainingMs}",
+ byTimeout ? "Lambda timeout" : "Client disconnect",
+ context.Request.Path,
+ lambdaContext?.RemainingTime.TotalMilliseconds);
+```
+
+### Sample Log Output
+
+```json
+{
+ "timestamp": "2024-01-15T14:30:25.123Z",
+ "level": "Warning",
+ "message": "Request cancelled (Lambda timeout). Path: /api/data, RemainingTimeMs: 245.7",
+ "properties": {
+ "Reason": "Lambda timeout",
+ "Path": "/api/data",
+ "RemainingTimeMs": 245.7
+ }
+}
+```
+
+## Best Practices
+
+### Placement in Pipeline
+
+Place the middleware **early** in the pipeline to ensure all downstream components receive the timeout-aware token:
+
+```csharp
+public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+{
+ // ✅ Good: Early placement
+ app.UseLambdaTimeoutLinkedCancellation();
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+ app.UseRouting();
+ app.UseEndpoints(endpoints => endpoints.MapControllers());
+}
+```
+
+### Service Registration
+
+Ensure your services accept and use cancellation tokens:
+
+```csharp
+public void ConfigureServices(IServiceCollection services)
+{
+ // Configure HttpClient with reasonable timeouts
+ services.AddHttpClient(client =>
+ {
+ client.Timeout = TimeSpan.FromSeconds(25); // Less than Lambda timeout
+ });
+
+ services.AddScoped();
+}
+```
+
+### Testing Timeout Scenarios
+
+```csharp
+[Test]
+public async Task Should_Handle_Cancellation_Gracefully()
+{
+ // Arrange
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
+ var service = new DataService(_httpClient, _logger);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => service.GetExternalDataAsync("test-id", cts.Token));
+}
+```
+
+## Troubleshooting
+
+### Common Issues
+
+#### 1. Timeout Too Short
+
+**Symptom**: Frequent 504 errors in logs
+**Solution**: Increase safety buffer or optimize application performance
+
+```csharp
+// Increase buffer if cleanup operations are heavy
+app.UseLambdaTimeoutLinkedCancellation(TimeSpan.FromMilliseconds(500));
+```
+
+#### 2. Services Not Respecting Cancellation
+
+**Symptom**: Application continues processing after timeout
+**Solution**: Ensure all async operations accept `CancellationToken`
+
+```csharp
+// ❌ Bad: No cancellation token
+await _httpClient.GetAsync(url);
+
+// ✅ Good: Respects cancellation
+await _httpClient.GetAsync(url, cancellationToken);
+```
+
+#### 3. Middleware Placed Too Late
+
+**Symptom**: Some requests don't get timeout protection
+**Solution**: Move middleware earlier in pipeline
+
+```csharp
+// ❌ Bad: After other middleware
+app.UseAuthentication();
+app.UseLambdaTimeoutLinkedCancellation(); // Too late!
+
+// ✅ Good: Early in pipeline
+app.UseLambdaTimeoutLinkedCancellation();
+app.UseAuthentication();
+```
+
+### Debugging Tips
+
+1. **Check Lambda logs** for timeout warnings
+2. **Monitor CloudWatch metrics** for Lambda duration patterns
+3. **Use structured logging** to correlate timeout events
+4. **Test locally** with short-lived cancellation tokens
+
+## Advanced Scenarios
+
+### Custom Timeout Logic
+
+For advanced scenarios where you need custom timeout behavior:
+
+```csharp
+public class CustomTimeoutMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILambdaContext _lambdaContext;
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ // Access the timeout-aware token set by LambdaTimeoutLinkMiddleware
+ var timeoutToken = context.RequestAborted;
+
+ // Your custom timeout logic here
+ using var customCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken);
+
+ // Add custom timeout conditions
+ if (ShouldUseCustomTimeout(context))
+ {
+ customCts.CancelAfter(TimeSpan.FromSeconds(5));
+ }
+
+ // Replace token for downstream middleware
+ context.RequestAborted = customCts.Token;
+
+ try
+ {
+ await _next(context);
+ }
+ finally
+ {
+ // Restore original token
+ context.RequestAborted = timeoutToken;
+ }
+ }
+}
+```
+
+## Performance Considerations
+
+- **Minimal Overhead**: Middleware adds < 1ms per request
+- **Memory Efficient**: Uses linked cancellation tokens, not polling
+- **GC Friendly**: Properly disposes all cancellation token sources
+- **Thread Safe**: All operations are thread-safe
+
+## Migration Guide
+
+### From Manual Timeout Handling
+
+If you previously handled Lambda timeouts manually:
+
+```csharp
+// ❌ Old manual approach
+public class OldController : ControllerBase
+{
+ [HttpGet]
+ public async Task GetData()
+ {
+ var lambdaContext = HttpContext.Items["LambdaContext"] as ILambdaContext;
+ using var cts = new CancellationTokenSource(lambdaContext.RemainingTime - TimeSpan.FromMilliseconds(250));
+
+ // Manual timeout management...
+ }
+}
+
+// ✅ New approach with middleware
+public class NewController : ControllerBase
+{
+ [HttpGet]
+ public async Task GetData(CancellationToken cancellationToken)
+ {
+ // Timeout is automatically handled by middleware
+ var data = await _service.GetDataAsync(cancellationToken);
+ return Ok(data);
+ }
+}
+```
\ No newline at end of file
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..886df39
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,108 @@
+site_name: LayeredCraft Lambda ASP.NET Core Hosting Extensions
+site_description: Extensions and middleware for Amazon.Lambda.AspNetCoreServer.Hosting
+site_author: LayeredCraft Team
+site_url: https://layeredcraft.github.io/lambda-aspnetcore-hosting-extensions/
+
+repo_name: LayeredCraft/lambda-aspnetcore-hosting-extensions
+repo_url: https://github.com/LayeredCraft/lambda-aspnetcore-hosting-extensions
+edit_uri: edit/main/docs/
+
+theme:
+ name: material
+ palette:
+ # Palette toggle for light mode
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/brightness-7
+ name: Switch to dark mode
+ # Palette toggle for dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/brightness-4
+ name: Switch to light mode
+ features:
+ - navigation.tabs
+ - navigation.tabs.sticky
+ - navigation.sections
+ - navigation.expand
+ - navigation.path
+ - navigation.top
+ - search.highlight
+ - search.share
+ - content.code.copy
+ - content.code.select
+ - content.tabs.link
+ icon:
+ repo: fontawesome/brands/github
+ edit: material/pencil
+ view: material/eye
+
+extra:
+ social:
+ - icon: fontawesome/brands/github
+ link: https://github.com/LayeredCraft
+ - icon: fontawesome/solid/globe
+ link: https://layeredcraft.github.io
+ generator: false
+
+markdown_extensions:
+ - admonition
+ - attr_list
+ - codehilite:
+ guess_lang: false
+ - def_list
+ - footnotes
+ - md_in_html
+ - tables
+ - toc:
+ permalink: true
+ - pymdownx.arithmatex:
+ generic: true
+ - pymdownx.betterem:
+ smart_enable: all
+ - pymdownx.caret
+ - pymdownx.details
+ - pymdownx.emoji:
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ - pymdownx.inlinehilite
+ - pymdownx.keys
+ - pymdownx.magiclink:
+ repo_url_shorthand: true
+ user: LayeredCraft
+ repo: lambda-aspnetcore-hosting-extensions
+ - pymdownx.mark
+ - pymdownx.smartsymbols
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: !!python/name:pymdownx.superfences.fence_code_format
+ - pymdownx.tabbed:
+ alternate_style: true
+ - pymdownx.tasklist:
+ custom_checkbox: true
+ - pymdownx.tilde
+
+plugins:
+ - search
+ - minify:
+ minify_html: true
+
+nav:
+ - Home: index.md
+ - Middleware:
+ - Lambda Timeout Middleware: middleware/lambda-timeout-middleware.md
+ - Examples: examples/index.md
+
+copyright: Copyright © 2024 LayeredCraft Team
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..6d58373
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+mkdocs>=1.5.3
+mkdocs-material>=9.4.6
+mkdocs-minify-plugin>=0.7.1
\ No newline at end of file
diff --git a/src/LayeredCraft.Lambda.AspNetCore.HostingExtensions/Extensions/ApplicationBuilderExtensions.cs b/src/LayeredCraft.Lambda.AspNetCore.HostingExtensions/Extensions/ApplicationBuilderExtensions.cs
new file mode 100644
index 0000000..c11bdee
--- /dev/null
+++ b/src/LayeredCraft.Lambda.AspNetCore.HostingExtensions/Extensions/ApplicationBuilderExtensions.cs
@@ -0,0 +1,32 @@
+using LayeredCraft.Lambda.AspNetCore.Hosting.Middleware;
+using Microsoft.AspNetCore.Builder;
+
+namespace LayeredCraft.Lambda.AspNetCore.Hosting.Extensions;
+
+///
+/// Extension methods for to configure Lambda-specific middleware.
+///
+public static class ApplicationBuilderExtensions
+{
+ ///
+ /// Adds Lambda timeout-aware cancellation to the application pipeline.
+ ///
+ /// The application builder.
+ ///
+ /// Optional safety buffer to subtract from Lambda timeout. Defaults to 250ms.
+ /// This ensures graceful shutdown before Lambda forcibly terminates the function.
+ ///
+ /// The application builder for method chaining.
+ ///
+ /// This middleware links Lambda execution timeout with HTTP request cancellation,
+ /// enabling downstream code to respond to approaching timeouts through standard
+ /// CancellationToken patterns. In local development environments, operates as
+ /// a pass-through with only client disconnect cancellation active.
+ ///
+ /// Should be placed early in the middleware pipeline to ensure all downstream
+ /// components receive the timeout-aware cancellation token.
+ ///
+ public static IApplicationBuilder UseLambdaTimeoutLinkedCancellation(
+ this IApplicationBuilder app, TimeSpan? safetyBuffer = null)
+ => app.UseMiddleware(safetyBuffer);
+}
\ No newline at end of file
diff --git a/src/LayeredCraft.Lambda.AspNetCore.HostingExtensions/LayeredCraft.Lambda.AspNetCore.HostingExtensions.csproj b/src/LayeredCraft.Lambda.AspNetCore.HostingExtensions/LayeredCraft.Lambda.AspNetCore.HostingExtensions.csproj
new file mode 100644
index 0000000..9da73ae
--- /dev/null
+++ b/src/LayeredCraft.Lambda.AspNetCore.HostingExtensions/LayeredCraft.Lambda.AspNetCore.HostingExtensions.csproj
@@ -0,0 +1,16 @@
+
+
+
+ enable
+ enable
+ LayeredCraft.Lambda.AspNetCore.Hosting
+ net8.0;net9.0
+ default
+
+
+
+
+
+
+
+
diff --git a/src/LayeredCraft.Lambda.AspNetCore.HostingExtensions/Middleware/LambdaTimeoutLinkMiddleware.cs b/src/LayeredCraft.Lambda.AspNetCore.HostingExtensions/Middleware/LambdaTimeoutLinkMiddleware.cs
new file mode 100644
index 0000000..1961a63
--- /dev/null
+++ b/src/LayeredCraft.Lambda.AspNetCore.HostingExtensions/Middleware/LambdaTimeoutLinkMiddleware.cs
@@ -0,0 +1,165 @@
+using Amazon.Lambda.AspNetCoreServer;
+using Amazon.Lambda.Core;
+using LayeredCraft.StructuredLogging;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace LayeredCraft.Lambda.AspNetCore.Hosting.Middleware;
+
+///
+/// Middleware that links Lambda timeout with HTTP request cancellation for proper timeout handling.
+///
+///
+/// This middleware creates a cancellation token that is triggered when either:
+///
+/// -
+/// The client disconnects or the server aborts the request (original token).
+///
+/// -
+/// Lambda timeout approaches (calculated from with a safety buffer).
+///
+///
+/// The middleware replaces with the linked token during request processing,
+/// enabling downstream code to respond to Lambda timeouts through standard patterns.
+/// In local development environments where is unavailable, the middleware
+/// operates as a pass-through with only the original token active.
+///
+public sealed class LambdaTimeoutLinkMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+ private readonly TimeSpan _buffer;
+
+ ///
+ /// Non-standard status code used by some proxies (e.g., nginx) to indicate the client closed the connection.
+ ///
+ private const int ClientClosedRequest = 499;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The next middleware delegate in the pipeline.
+ /// The logger for recording timeout events.
+ ///
+ /// The safety buffer to subtract from the Lambda timeout. Defaults to 250ms to allow graceful shutdown/flush.
+ ///
+ /// Thrown when or is null.
+ /// Thrown when is negative.
+ public LambdaTimeoutLinkMiddleware(
+ RequestDelegate next,
+ ILogger logger,
+ TimeSpan? safetyBuffer = null)
+ {
+ _next = next ?? throw new ArgumentNullException(nameof(next));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ var buffer = safetyBuffer ?? TimeSpan.FromMilliseconds(250);
+ if (buffer < TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(safetyBuffer), "Safety buffer cannot be negative.");
+ _buffer = buffer;
+ }
+
+ ///
+ /// Processes the HTTP request with Lambda timeout–aware cancellation.
+ ///
+ /// The current HTTP context.
+ /// A task that represents the asynchronous operation.
+ ///
+ /// On cancellation due to Lambda timeout or client disconnect, this middleware will stop the pipeline,
+ /// set an appropriate status code (504 for timeout, 499 for client disconnect), and avoid writing a body.
+ ///
+ public async Task InvokeAsync(HttpContext context)
+{
+ ArgumentNullException.ThrowIfNull(context);
+
+ var original = context.RequestAborted;
+
+ var lambdaContext = context.Items.TryGetValue(AbstractAspNetCoreFunction.LAMBDA_CONTEXT, out var ctxObj)
+ ? ctxObj as ILambdaContext
+ : null;
+
+ var timeoutDuration = lambdaContext is null
+ ? TimeSpan.FromDays(1)
+ : GetTimeLeft(lambdaContext.RemainingTime, _buffer);
+
+ // Short-circuit: already out of time
+ if (timeoutDuration <= TimeSpan.Zero)
+ {
+ if (!context.Response.HasStarted)
+ {
+ context.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
+ context.Response.ContentLength = 0;
+ }
+ return;
+ }
+
+ using var timeoutCts = new CancellationTokenSource(timeoutDuration);
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(original, timeoutCts.Token);
+
+ var cancelledByTimeout = false;
+ var cancelledByClient = false;
+ DateTimeOffset? timeoutAt = null, clientCancelAt = null;
+
+ using var _regClient = original.Register(() => { cancelledByClient = true; clientCancelAt = DateTimeOffset.UtcNow; });
+ using var _regTimeout = timeoutCts.Token.Register(() => { cancelledByTimeout = true; timeoutAt = DateTimeOffset.UtcNow; });
+
+ // Expose the linked token to downstream
+ context.RequestAborted = linkedCts.Token;
+
+ try
+ {
+ await _next(context).ConfigureAwait(false);
+
+ // If something cancelled but downstream didn't throw, finalize here
+ if (linkedCts.IsCancellationRequested && !context.Response.HasStarted)
+ {
+ var byTimeout =
+ cancelledByTimeout && !cancelledByClient
+ || (cancelledByTimeout && cancelledByClient && timeoutAt <= clientCancelAt);
+
+ context.Response.StatusCode = byTimeout
+ ? StatusCodes.Status504GatewayTimeout
+ : ClientClosedRequest; // consider 504 here too
+ context.Response.ContentLength = 0;
+ }
+ }
+ catch (OperationCanceledException) when (linkedCts.IsCancellationRequested)
+ {
+ var byTimeout =
+ cancelledByTimeout && !cancelledByClient
+ || (cancelledByTimeout && cancelledByClient && timeoutAt <= clientCancelAt);
+
+ _logger.LogWarning(
+ "Request cancelled ({Reason}). Path: {Path}, RemainingTimeMs: {RemainingMs}, TimeoutDurationMs: {TimeoutMs}",
+ byTimeout ? "Lambda timeout" : "Client disconnect",
+ context.Request.Path,
+ lambdaContext?.RemainingTime.TotalMilliseconds,
+ timeoutDuration.TotalMilliseconds);
+
+ if (!context.Response.HasStarted)
+ {
+ context.Response.StatusCode = byTimeout
+ ? StatusCodes.Status504GatewayTimeout
+ : ClientClosedRequest; // or 504
+ context.Response.ContentLength = 0;
+ }
+ // swallow; cancelled pipeline
+ }
+ finally
+ {
+ context.RequestAborted = original;
+ }
+}
+
+ ///
+ /// Calculates the time remaining before Lambda timeout, accounting for the safety buffer.
+ ///
+ /// The remaining Lambda execution time from .
+ /// The safety buffer to subtract.
+ ///
+ /// The effective timeout duration for the cancellation token, or
+ /// if insufficient time remains.
+ ///
+ private static TimeSpan GetTimeLeft(TimeSpan remaining, TimeSpan buffer)
+ => remaining > buffer ? remaining - buffer : TimeSpan.Zero;
+}
diff --git a/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/Extensions/ApplicationBuilderExtensionsTests.cs b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/Extensions/ApplicationBuilderExtensionsTests.cs
new file mode 100644
index 0000000..099eacd
--- /dev/null
+++ b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/Extensions/ApplicationBuilderExtensionsTests.cs
@@ -0,0 +1,19 @@
+using LayeredCraft.Lambda.AspNetCore.Hosting.Extensions;
+using LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.TestKit.Attributes;
+using Microsoft.AspNetCore.Builder;
+
+namespace LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.Extensions;
+
+public class ApplicationBuilderExtensionsTests
+{
+ [Theory]
+ [BaseAutoData]
+ public void UseLambdaTimeoutLinkedCancellation_WithValidApplicationBuilder_ShouldNotThrow(IApplicationBuilder app)
+ {
+ // Act
+ var action = () => app.UseLambdaTimeoutLinkedCancellation();
+
+ // Assert
+ action.Should().NotThrow("Extension method should register middleware without throwing");
+ }
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.csproj b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.csproj
new file mode 100644
index 0000000..1a03a15
--- /dev/null
+++ b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.csproj
@@ -0,0 +1,39 @@
+
+
+
+ enable
+ enable
+ Exe
+ LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests
+ true
+ true
+ net8.0;net9.0
+ default
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/Middleware/LambdaTimeoutLinkMiddlewareTests.cs b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/Middleware/LambdaTimeoutLinkMiddlewareTests.cs
new file mode 100644
index 0000000..17dec3b
--- /dev/null
+++ b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/Middleware/LambdaTimeoutLinkMiddlewareTests.cs
@@ -0,0 +1,261 @@
+using System.Net;
+using Amazon.Lambda.Core;
+using AutoFixture.Xunit3;
+using LayeredCraft.Lambda.AspNetCore.Hosting.Middleware;
+using LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.TestKit.Attributes;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.Middleware;
+
+public class LambdaTimeoutLinkMiddlewareTests
+{
+ [Theory]
+ [MiddlewareAutoData]
+ public void Constructor_WithValidParameters_ShouldCreateInstance(RequestDelegate next,
+ ILogger logger, TimeSpan timeout)
+ {
+ // Act
+ var middleware = new LambdaTimeoutLinkMiddleware(next, logger, timeout);
+
+ // Assert
+ middleware.Should().NotBeNull("Middleware should be created with valid parameters");
+ }
+
+ ///
+ /// Verifies that constructor throws when next delegate is null.
+ ///
+ [Theory]
+ [MiddlewareAutoData]
+ public void Constructor_WithNullNext_ShouldThrowArgumentNullException(
+ ILogger logger)
+ {
+ // Act
+ var action = () => new LambdaTimeoutLinkMiddleware(null!, logger);
+
+ // Assert
+ action.Should().Throw()
+ .WithParameterName("next", "Constructor should validate next parameter");
+ }
+
+ ///
+ /// Verifies that constructor throws when logger is null.
+ ///
+ [Theory]
+ [MiddlewareAutoData]
+ public void Constructor_WithNullLogger_ShouldThrowArgumentNullException(
+ RequestDelegate next)
+ {
+ // Act
+ var action = () => new LambdaTimeoutLinkMiddleware(next, null!);
+
+ // Assert
+ action.Should().Throw()
+ .WithParameterName("logger", "Constructor should validate logger parameter");
+ }
+
+ ///
+ /// Verifies that constructor throws when safety buffer is negative.
+ ///
+ [Theory]
+ [MiddlewareAutoData]
+ public void Constructor_WithNegativeSafetyBuffer_ShouldThrowArgumentOutOfRangeException(
+ RequestDelegate next, ILogger logger)
+ {
+ // Arrange
+ var negativeSafetyBuffer = TimeSpan.FromMilliseconds(-100);
+
+ // Act
+ var action = () => new LambdaTimeoutLinkMiddleware(next, logger, negativeSafetyBuffer);
+
+ // Assert
+ action.Should().Throw()
+ .WithParameterName("safetyBuffer")
+ .WithMessage("Safety buffer cannot be negative.*");
+ }
+
+ ///
+ /// Verifies that when no Lambda context is present (local development),
+ /// the middleware operates in pass-through mode with linked cancellation tokens.
+ ///
+ [Theory]
+ [MiddlewareAutoData]
+ public async Task InvokeAsync_WithNoLambdaContext_ShouldCallNextMiddlewareWithLinkedCancellationToken(
+ [Frozen] HttpContext nullLambdaContext, [Frozen] RequestDelegate capturingNext,
+ ILogger logger)
+ {
+ // Arrange
+ var originalToken = nullLambdaContext.RequestAborted;
+ var sut = new LambdaTimeoutLinkMiddleware(capturingNext, logger);
+
+ // Act
+ await sut.InvokeAsync(nullLambdaContext);
+
+ // Assert
+ var nextCalled = nullLambdaContext.Items.ContainsKey("NextCalled") &&
+ (bool)nullLambdaContext.Items["NextCalled"]!;
+ var tokenDuringExecution = nullLambdaContext.Items["CapturedToken"] as CancellationToken? ?? CancellationToken.None;
+
+ nextCalled.Should().BeTrue("Next middleware should have been called");
+ tokenDuringExecution.Should().NotBe(originalToken,
+ "Middleware should replace RequestAborted with linked token during execution");
+ nullLambdaContext.RequestAborted.Should().Be(originalToken,
+ "Original RequestAborted token should be restored after execution");
+ nullLambdaContext.Items.Should().NotContainKey("LambdaContext",
+ "No Lambda context should be present in local development scenario");
+ }
+
+ ///
+ /// Verifies that InvokeAsync throws ArgumentNullException when context is null.
+ ///
+ [Theory]
+ [MiddlewareAutoData]
+ public async Task InvokeAsync_WithNullContext_ShouldThrowArgumentNullException(
+ RequestDelegate next, ILogger logger)
+ {
+ // Arrange
+ var sut = new LambdaTimeoutLinkMiddleware(next, logger);
+
+ // Act
+ var action = async () => await sut.InvokeAsync(null!);
+
+ // Assert
+ await action.Should().ThrowAsync()
+ .WithParameterName("context", "InvokeAsync should validate context parameter");
+ }
+
+ ///
+ /// Verifies that when Lambda context is present, the middleware links both
+ /// timeout and request cancellation tokens properly.
+ ///
+ [Theory]
+ [MiddlewareAutoData]
+ public async Task InvokeAsync_WithLambdaContext_ShouldLinkTimeoutAndRequestTokens(
+ [Frozen] HttpContext withLambdaContext, [Frozen] RequestDelegate capturingNext,
+ [Frozen] ILambdaContext lambdaContext,
+ ILogger logger)
+ {
+ // Arrange
+ lambdaContext.RemainingTime.Returns(TimeSpan.FromMinutes(5)); // Ensure sufficient time for normal execution
+ var originalToken = withLambdaContext.RequestAborted;
+ var sut = new LambdaTimeoutLinkMiddleware(next: capturingNext, logger);
+
+ // Act
+ await sut.InvokeAsync(withLambdaContext);
+
+ // Assert
+ var nextCalled = withLambdaContext.Items.ContainsKey("NextCalled") &&
+ (bool)withLambdaContext.Items["NextCalled"]!;
+ var tokenDuringExecution = withLambdaContext.Items["CapturedToken"] as CancellationToken? ?? CancellationToken.None;
+
+ nextCalled.Should().BeTrue("Next middleware should have been called");
+ tokenDuringExecution.Should().NotBe(originalToken,
+ "Middleware should replace RequestAborted with linked token during execution");
+ withLambdaContext.RequestAborted.Should().Be(originalToken,
+ "Original RequestAborted token should be restored after execution");
+ withLambdaContext.Items.Should().ContainKey("LambdaContext",
+ "Lambda context should be present in Lambda environment scenario");
+ }
+
+ ///
+ /// Verifies that when Lambda timeout triggers cancellation during execution,
+ /// the middleware handles it gracefully with proper status code and logging.
+ ///
+ [Theory]
+ [MiddlewareAutoData]
+ public async Task InvokeAsync_WithLambdaTimeoutCancellation_ShouldSetGatewayTimeoutStatus(
+ [Frozen] HttpContext withLambdaContext, [Frozen] RequestDelegate timeoutDelegate,
+ [Frozen] ILambdaContext lambdaContext,
+ ILogger logger)
+ {
+ // Arrange
+ lambdaContext.RemainingTime.Returns(TimeSpan.FromMilliseconds(50)); // Very short timeout
+ var sut = new LambdaTimeoutLinkMiddleware(timeoutDelegate, logger, TimeSpan.FromMilliseconds(10));
+
+ // Act
+ await sut.InvokeAsync(withLambdaContext);
+
+ // Assert
+ withLambdaContext.Response.StatusCode.Should().Be(StatusCodes.Status504GatewayTimeout,
+ "Lambda timeout should result in 504 Gateway Timeout status code");
+ withLambdaContext.Response.ContentLength.Should().Be(0,
+ "Response body should be empty on timeout");
+ }
+
+ ///
+ /// Verifies that when client disconnect triggers cancellation during execution,
+ /// the middleware handles it gracefully with proper status code.
+ ///
+ [Theory]
+ [MiddlewareAutoData]
+ public async Task InvokeAsync_WithClientDisconnectCancellation_ShouldSetClientClosedStatus(
+ [Frozen] HttpContext preCancelledContext, [Frozen] RequestDelegate disconnectDelegate,
+ [Frozen] ILambdaContext lambdaContext,
+ ILogger logger)
+ {
+ // Arrange
+ lambdaContext.RemainingTime.Returns(TimeSpan.FromMinutes(5)); // Long timeout to avoid timeout trigger
+ preCancelledContext.RequestAborted.IsCancellationRequested.Should().BeTrue("Context should have pre-cancelled token");
+ var sut = new LambdaTimeoutLinkMiddleware(disconnectDelegate, logger, TimeSpan.FromMinutes(1)); // Large buffer
+
+ // Act
+ await sut.InvokeAsync(preCancelledContext);
+
+ // Assert
+ preCancelledContext.Response.StatusCode.Should().Be(499,
+ "Client disconnect should result in 499 Client Closed Request status code");
+ preCancelledContext.Response.ContentLength.Should().Be(0,
+ "Response body should be empty on client disconnect");
+ }
+
+ ///
+ /// Verifies that when response has already started, the middleware doesn't try to modify it.
+ ///
+ [Theory]
+ [MiddlewareAutoData]
+ public async Task InvokeAsync_WithResponseAlreadyStarted_ShouldNotModifyResponse(
+ [Frozen] HttpContext responseStarted, [Frozen] RequestDelegate timeoutDelegate,
+ [Frozen] ILambdaContext lambdaContext,
+ ILogger logger)
+ {
+ // Arrange
+ lambdaContext.RemainingTime.Returns(TimeSpan.FromMilliseconds(50)); // Short timeout
+ responseStarted.Response.HasStarted.Should().BeTrue("Response should be already started");
+ var originalStatusCode = responseStarted.Response.StatusCode; // Should be 200 from MockStartedResponseFeature
+
+ var sut = new LambdaTimeoutLinkMiddleware(timeoutDelegate, logger, TimeSpan.FromMilliseconds(10));
+
+ // Act
+ await sut.InvokeAsync(responseStarted);
+
+ // Assert - Response should remain unchanged when already started
+ responseStarted.Response.StatusCode.Should().Be(originalStatusCode,
+ "Status code should not be modified when response has already started");
+ }
+
+ ///
+ /// Verifies that when time has already expired, the middleware short-circuits immediately.
+ ///
+ [Theory]
+ [MiddlewareAutoData]
+ public async Task InvokeAsync_WithExpiredTime_ShouldShortCircuitWithTimeoutStatus(
+ [Frozen] HttpContext withLambdaContext, [Frozen] RequestDelegate trackingNext,
+ [Frozen] ILambdaContext lambdaContext,
+ ILogger logger)
+ {
+ // Arrange
+ lambdaContext.RemainingTime.Returns(TimeSpan.FromMilliseconds(100)); // Short time
+ var sut = new LambdaTimeoutLinkMiddleware(trackingNext, logger, TimeSpan.FromMilliseconds(200)); // Buffer larger than remaining
+
+ // Act
+ await sut.InvokeAsync(withLambdaContext);
+
+ // Assert
+ var nextCalled = withLambdaContext.Items.ContainsKey("NextCalled");
+ nextCalled.Should().BeFalse("Next middleware should not be called when time is already expired");
+ withLambdaContext.Response.StatusCode.Should().Be(StatusCodes.Status504GatewayTimeout,
+ "Should immediately return 504 when time is already expired");
+ withLambdaContext.Response.ContentLength.Should().Be(0,
+ "Should set content length to 0");
+ }
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/Attributes/BaseAutoDataAttribute.cs b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/Attributes/BaseAutoDataAttribute.cs
new file mode 100644
index 0000000..ce8c48d
--- /dev/null
+++ b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/Attributes/BaseAutoDataAttribute.cs
@@ -0,0 +1,20 @@
+using AutoFixture;
+using AutoFixture.Xunit3;
+
+namespace LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.TestKit.Attributes;
+
+public sealed class BaseAutoDataAttribute() : AutoDataAttribute(CreateFixture)
+{
+ internal static IFixture CreateFixture()
+ {
+ return BaseFixtureFactory.CreateFixture(fixture =>
+ {
+ // Add any common customizations for all tests here if needed in the future
+ });
+ }
+}
+
+public sealed class InlineBaseAutoDataAttribute(params object[] values)
+ : InlineAutoDataAttribute(BaseAutoDataAttribute.CreateFixture, values)
+{
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/Attributes/MiddlewareAutoDataAttribute.cs b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/Attributes/MiddlewareAutoDataAttribute.cs
new file mode 100644
index 0000000..4d92092
--- /dev/null
+++ b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/Attributes/MiddlewareAutoDataAttribute.cs
@@ -0,0 +1,27 @@
+using Amazon.Lambda.Core;
+using AutoFixture;
+using AutoFixture.Xunit3;
+using LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.TestKit.SpecimenBuilders;
+
+namespace LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.TestKit.Attributes;
+
+public sealed class MiddlewareAutoDataAttribute() : AutoDataAttribute(CreateFixture)
+{
+ internal static IFixture CreateFixture()
+ {
+ return BaseFixtureFactory.CreateFixture(fixture =>
+ {
+ fixture.Freeze();
+ // Add Middleware-specific customizations
+ fixture.Customizations.Add(new HttpContextSpecimenBuilder());
+ fixture.Customizations.Add(new ApplicationBuilderSpecimenBuilder());
+ fixture.Customizations.Add(new RequestDelegateSpecimenBuilder());
+ });
+ }
+}
+
+public sealed class InlineMiddlewareAutoDataAttribute(params object[] values)
+ : InlineAutoDataAttribute(MiddlewareAutoDataAttribute.CreateFixture, values)
+{
+ // This class allows for inline data to be combined with the MiddlewareAutoDataAttribute customizations
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/BaseFixtureFactory.cs b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/BaseFixtureFactory.cs
new file mode 100644
index 0000000..e72afe2
--- /dev/null
+++ b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/BaseFixtureFactory.cs
@@ -0,0 +1,33 @@
+using AutoFixture;
+using AutoFixture.AutoNSubstitute;
+
+namespace LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.TestKit;
+
+///
+/// Base class that provides common fixture configuration for AutoData attributes.
+///
+public static class BaseFixtureFactory
+{
+ ///
+ /// Creates a fixture with common configuration and allows custom specimen builders to be added.
+ ///
+ /// Action to customize the fixture with specific specimen builders
+ /// Configured fixture
+ public static IFixture CreateFixture(Action customizeAction)
+ {
+ var fixture = new Fixture();
+
+ // Remove throwing recursion behavior and add omit on recursion behavior
+ fixture.Behaviors.OfType().ToList()
+ .ForEach(b => fixture.Behaviors.Remove(b));
+ fixture.Behaviors.Add(new OmitOnRecursionBehavior());
+
+ // Add AutoNSubstitute customization for automatic interface mocking
+ fixture.Customize(new AutoNSubstituteCustomization { ConfigureMembers = true });
+
+ // Allow customization with specific specimen builders
+ customizeAction(fixture);
+
+ return fixture;
+ }
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/RequestSpecifications/ApplicationBuilderSpecification.cs b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/RequestSpecifications/ApplicationBuilderSpecification.cs
new file mode 100644
index 0000000..1b97fa2
--- /dev/null
+++ b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/RequestSpecifications/ApplicationBuilderSpecification.cs
@@ -0,0 +1,14 @@
+using System.Reflection;
+using AutoFixture.Kernel;
+using Microsoft.AspNetCore.Builder;
+
+namespace LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.TestKit.RequestSpecifications;
+
+public class ApplicationBuilderSpecification : IRequestSpecification
+{
+ public bool IsSatisfiedBy(object request)
+ {
+ return request is ParameterInfo parameterInfo && parameterInfo.ParameterType == typeof(IApplicationBuilder) ||
+ request is Type type && type == typeof(IApplicationBuilder);
+ }
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/SpecimenBuilders/ApplicationBuilderSpecimenBuilder.cs b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/SpecimenBuilders/ApplicationBuilderSpecimenBuilder.cs
new file mode 100644
index 0000000..4744d0c
--- /dev/null
+++ b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/SpecimenBuilders/ApplicationBuilderSpecimenBuilder.cs
@@ -0,0 +1,31 @@
+using AutoFixture.Kernel;
+using LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.TestKit.RequestSpecifications;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+
+namespace LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.TestKit.SpecimenBuilders;
+
+public class ApplicationBuilderSpecimenBuilder(IRequestSpecification requestSpecification) : ISpecimenBuilder
+{
+ public ApplicationBuilderSpecimenBuilder() : this(new ApplicationBuilderSpecification())
+ {
+ }
+
+ public object Create(object request, ISpecimenContext context)
+ {
+ if (!requestSpecification.IsSatisfiedBy(request))
+ {
+ return new NoSpecimen();
+ }
+
+ var services = new ServiceCollection();
+ services.AddLogging();
+ var serviceProvider = services.BuildServiceProvider();
+
+ var app = Substitute.For();
+ app.ApplicationServices.Returns(serviceProvider);
+
+ return app;
+ }
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/SpecimenBuilders/HttpContextSpecimenBuilder.cs b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/SpecimenBuilders/HttpContextSpecimenBuilder.cs
new file mode 100644
index 0000000..b2277e5
--- /dev/null
+++ b/test/LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests/TestKit/SpecimenBuilders/HttpContextSpecimenBuilder.cs
@@ -0,0 +1,87 @@
+using System.Reflection;
+using Amazon.Lambda.AspNetCoreServer;
+using Amazon.Lambda.Core;
+using AutoFixture.Kernel;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+
+namespace LayeredCraft.Lambda.AspNetCore.HostingExtensions.Tests.TestKit.SpecimenBuilders;
+
+public class HttpContextSpecimenBuilder : ISpecimenBuilder
+{
+ public object Create(object request, ISpecimenContext context)
+ {
+ return request switch
+ {
+ ParameterInfo parameterInfo when parameterInfo.ParameterType == typeof(HttpContext) => parameterInfo.Name?.ToLowerInvariant()
+ switch
+ {
+ "nulllambdacontext" or "nolambdacontext" => CreateDefaultHttpContext(context, hasLambdaContext: false),
+ "withlambdacontext" or "lambdacontext" => CreateDefaultHttpContext(context, hasLambdaContext: true),
+ "responsestarted" => CreateDefaultHttpContext(context, hasLambdaContext: true, responseStarted: true),
+ "precancelledcontext" => CreateDefaultHttpContext(context, hasLambdaContext: true, preCancelled: true),
+ _ => CreateDefaultHttpContext(context)
+ },
+ Type type when type == typeof(HttpContext) => CreateDefaultHttpContext(context),
+ _ => new NoSpecimen()
+ };
+ }
+
+ private static HttpContext CreateDefaultHttpContext(ISpecimenContext context, bool hasLambdaContext = true, bool responseStarted = false, bool preCancelled = false)
+ {
+ var httpContext = new DefaultHttpContext
+ {
+ Request =
+ {
+ // Configure request with valid defaults
+ Method = "GET",
+ Path = "/api/test",
+ Scheme = "https",
+ Host = new HostString("localhost")
+ }
+ };
+
+ // Initialize response headers collection
+ httpContext.Response.Headers.Clear();
+
+ if (hasLambdaContext)
+ {
+ var lambdaContext = context.Resolve(typeof(ILambdaContext));
+ httpContext.Items[AbstractAspNetCoreFunction.LAMBDA_CONTEXT] = lambdaContext;
+ }
+
+ if (responseStarted)
+ {
+ // Simulate response already started by setting StatusCode
+ httpContext.Response.StatusCode = 200;
+ // In a real scenario, writing to the response would start it, but we can't easily simulate that
+ // The middleware checks HasStarted, so we need to simulate that condition
+ httpContext.Features.Set(new MockStartedResponseFeature());
+ }
+
+ if (preCancelled)
+ {
+ // Create a pre-cancelled token
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+ httpContext.RequestAborted = cts.Token;
+ }
+
+ return httpContext;
+ }
+
+ ///
+ /// Mock response feature that simulates a started response
+ ///
+ private class MockStartedResponseFeature : IHttpResponseFeature
+ {
+ public Stream Body { get; set; } = Stream.Null;
+ public bool HasStarted => true; // This is the key property we need
+ public IHeaderDictionary Headers { get; set; } = new HeaderDictionary();
+ public string? ReasonPhrase { get; set; }
+ public int StatusCode { get; set; } = 200;
+
+ public void OnCompleted(Func