Improves HTTP context loss error handling (#7187)

* Remove bookmark-based resumption logic for lost HTTP context scenarios in `WriteHttpResponse` and `WriteFileHttpResponse`.

Simplify error handling by throwing descriptive exceptions for workflows resuming without an available HTTP context.

* Add integration tests for HTTP context loss scenarios in `WriteHttpResponse` and `WriteFileHttpResponse`.

* Add integration tests for handling HTTP context loss in response activities.

* Replace bookmark-based logic with fault exceptions in tests for `WriteHttpResponse` and `WriteFileHttpResponse` when HTTP context is unavailable.

* Update agent-logs/http-context-loss-error-messaging.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update test/integration/Elsa.Http.IntegrationTests/Elsa.Http.IntegrationTests.csproj

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2026-01-12 16:51:30 +01:00 committed by GitHub
parent 0e33270267
commit ad87ab96fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 326 additions and 99 deletions

View file

@ -327,6 +327,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience.Core.UnitTe
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Common.UnitTests", "test\unit\Elsa.Common.UnitTests\Elsa.Common.UnitTests.csproj", "{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Http.IntegrationTests", "test\integration\Elsa.Http.IntegrationTests\Elsa.Http.IntegrationTests.csproj", "{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -591,6 +593,10 @@ Global
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Release|Any CPU.Build.0 = Release|Any CPU
{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -694,6 +700,7 @@ Global
{874F5A44-DB06-47AB-A18C-2D13942E0147} = {477C2416-312D-46AE-BCD6-8FA1FAB43624}
{B8006D70-1630-43DB-A043-FA89FAC70F37} = {18453B51-25EB-4317-A4B3-B10518252E92}
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC} = {18453B51-25EB-4317-A4B3-B10518252E92}
{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -0,0 +1,94 @@
# Improved Error Messaging for HTTP Context Loss in Workflows
## Issue
When a workflow is initiated from an HTTP endpoint and later suspended or transitioned to a different execution context (e.g., background processing, virtual actor), the HTTP context becomes unavailable. Previously, the error message was generic and didn't clearly explain the cause of the failure.
## Solution
Enhanced error messages in HTTP response activities to clearly describe the HTTP context loss scenario, and simplified the logic by removing bookmark creation - now these activities will always fault immediately when the HTTP context is not available.
## Changes Made
### 1. WriteHttpResponse.cs
**File:** `src/modules/Elsa.Http/Activities/WriteHttpResponse.cs`
#### Removed bookmark creation logic
- Previously, when HTTP context was null, the activity would create a bookmark with `BookmarkMetadata.HttpCrossBoundary` to allow resumption
- Now the activity **always faults immediately** when HTTP context is null
#### Removed OnResumeAsync method
- The `OnResumeAsync` callback method has been completely removed as it's no longer needed
#### Updated ExecuteAsync to always fault
- When `httpContext == null`, immediately throws `FaultException` with detailed message
- Message explains:
- What happened: "The HTTP context was lost during workflow execution"
- Why it happened: "workflow initiated from an HTTP endpoint is suspended and later resumed in a different execution context"
- Examples: "background processing, virtual actor, or after a workflow transition"
- Impact: "The original HTTP request context that expects a response is no longer available"
### 2. WriteFileHttpResponse.cs
**File:** `src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs`
Applied the same changes for consistency:
#### Removed bookmark creation logic
- No longer creates a bookmark when HTTP context is null
#### Removed OnResumeAsync method
- The resume callback has been completely removed
#### Updated ExecuteAsync to always fault
- Applied the same comprehensive error message as WriteHttpResponse
## Benefits
1. **Immediate Failure**: Workflows fail fast when HTTP context is lost, making issues immediately visible
2. **Clear Troubleshooting**: Users get explicit information about HTTP context loss with detailed explanation
3. **Better Incident Reporting**: Error messages now clearly describe the synchronization issue between workflow execution and HTTP request context
4. **Simplified Logic**: Removed the bookmark/resume pattern that could lead to confusing suspended states
5. **Consistency**: Both HTTP response activities (WriteHttpResponse and WriteFileHttpResponse) now have identical behavior and error messaging
## Technical Details
- **Fault Code**: `HttpFaultCodes.NoHttpContext`
- **Fault Category**: `HttpFaultCategories.Http`
- **Fault Type**: `DefaultFaultTypes.System`
- **Behavior**: Immediate fault when `IHttpContextAccessor.HttpContext` is `null`
## Breaking Change Note
This is a **behavior change**:
- **Before**: Activities would create a bookmark and suspend when HTTP context was null, potentially allowing resume in a different context
- **After**: Activities always fault immediately when HTTP context is null
This change makes the failure mode more predictable and easier to diagnose, as workflows will no longer enter suspended states due to missing HTTP context.
## Testing
- No compilation errors introduced
- Changes are backward compatible in terms of API surface
- The fault code and structure remain the same for any existing error handling logic
- Workflows that relied on bookmark creation for cross-boundary execution will now fault instead
### Integration Test Project Created
A new integration test project has been created at `test/integration/Elsa.Http.IntegrationTests/` to cover the HTTP context loss behavior:
**Project Structure:**
- `Elsa.Http.IntegrationTests.csproj` - Test project file
- `Activities/HttpContextLossTests.cs` - Integration tests for HTTP context loss scenarios
- `README.md` - Documentation for the test project
- `Usings.cs` - Global using directives
**Test Scenarios:**
1. `WriteHttpResponse_WithNoHttpContext_ShouldRecordIncident` - Verifies that WriteHttpResponse records an incident with the detailed error message when HTTP context is null
2. `WriteFileHttpResponse_WithNoHttpContext_ShouldRecordIncident` - Verifies that WriteFileHttpResponse records an incident when HTTP context is null
**Current Status:**
The test project compiles and runs successfully. The integration tests are discovered by the xUnit test runner and currently all tests pass (2 tests passing), in line with the repository README.

View file

@ -71,11 +71,11 @@ public class WriteFileHttpResponse : Activity
if (httpContext == null)
{
// We're executing in a non-HTTP context (e.g. in a virtual actor).
// Create a bookmark to allow the invoker to export the state and resume execution from there.
context.CreateBookmark(OnResumeAsync, BookmarkMetadata.HttpCrossBoundary);
return;
throw new FaultException(
HttpFaultCodes.NoHttpContext,
HttpFaultCategories.Http,
DefaultFaultTypes.System,
"The HTTP context was lost during workflow execution. This typically occurs when a workflow initiated from an HTTP endpoint is suspended and later resumed in a different execution context (e.g., background processing, virtual actor, or after a workflow transition). The original HTTP request context that expects a response is no longer available.");
}
await WriteResponseAsync(context, httpContext);
@ -294,15 +294,5 @@ public class WriteFileHttpResponse : Activity
throw new HttpBadRequestException("Failed to parse If-Match header value", e);
}
}
}
private async ValueTask OnResumeAsync(ActivityExecutionContext context)
{
var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>();
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
throw new FaultException(HttpFaultCodes.NoHttpContext, HttpFaultCategories.Http, DefaultFaultTypes.System, "Cannot execute in a non-HTTP context");
await WriteResponseAsync(context, httpContext);
}
}

View file

@ -82,24 +82,11 @@ public class WriteHttpResponse : Activity
if (httpContext == null)
{
// We're executing in a non-HTTP context (e.g. in a virtual actor).
// Create a bookmark to allow the invoker to export the state and resume execution from there.
context.CreateBookmark(OnResumeAsync, BookmarkMetadata.HttpCrossBoundary);
return;
}
await WriteResponseAsync(context, httpContext.Response);
}
private async ValueTask OnResumeAsync(ActivityExecutionContext context)
{
var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>();
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
// We're not in an HTTP context, so let's fail.
throw new FaultException(HttpFaultCodes.NoHttpContext, HttpFaultCategories.Http, DefaultFaultTypes.System, "Cannot execute in a non-HTTP context");
throw new FaultException(
HttpFaultCodes.NoHttpContext,
HttpFaultCategories.Http,
DefaultFaultTypes.System,
"The HTTP context was lost during workflow execution. This typically occurs when a workflow initiated from an HTTP endpoint is suspended and later resumed in a different execution context (e.g., background processing, virtual actor, or after a workflow transition). The original HTTP request context that expects a response is no longer available.");
}
await WriteResponseAsync(context, httpContext.Response);

View file

@ -0,0 +1,57 @@
using Elsa.Http.IntegrationTests.Activities.Workflows;
using Elsa.Http.IntegrationTests.Helpers;
using Elsa.Testing.Shared;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Http.IntegrationTests.Activities;
/// <summary>
/// Integration tests for HTTP response activities when HTTP context is lost.
/// </summary>
public class HttpContextLossTests
{
private readonly WorkflowTestFixture _fixture;
public HttpContextLossTests(ITestOutputHelper testOutputHelper)
{
_fixture = new WorkflowTestFixture(testOutputHelper)
.ConfigureServices(services =>
{
// Register a null HTTP context accessor to simulate context loss
services.AddSingleton<IHttpContextAccessor>(new NullHttpContextAccessor());
});
}
[Fact(DisplayName = "WriteHttpResponse should record incident when HTTP context is null")]
public async Task WriteHttpResponse_WithNoHttpContext_ShouldRecordIncident()
{
// Act
var result = await _fixture.RunWorkflowAsync<WriteHttpResponseWithoutHttpContextWorkflow>();
// Assert
// Verify an incident was recorded
Assert.NotEmpty(result.WorkflowState.Incidents);
var incident = result.WorkflowState.Incidents.First();
Assert.Contains("HTTP context was lost", incident.Message);
Assert.Contains("background processing, virtual actor, or after a workflow transition", incident.Message);
}
[Fact(DisplayName = "WriteFileHttpResponse should record incident when HTTP context is null")]
public async Task WriteFileHttpResponse_WithNoHttpContext_ShouldRecordIncident()
{
// Act
var result = await _fixture.RunWorkflowAsync<WriteFileHttpResponseWithoutHttpContextWorkflow>();
// Assert
// Verify an incident was recorded
Assert.NotEmpty(result.WorkflowState.Incidents);
var incident = result.WorkflowState.Incidents.First();
Assert.Contains("HTTP context was lost", incident.Message);
Assert.Contains("background processing, virtual actor, or after a workflow transition", incident.Message);
}
}

View file

@ -0,0 +1,19 @@
using Elsa.Workflows;
namespace Elsa.Http.IntegrationTests.Activities.Workflows;
/// <summary>
/// A workflow that attempts to write a file HTTP response without HTTP context.
/// </summary>
internal class WriteFileHttpResponseWithoutHttpContextWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder builder)
{
builder.Root = new WriteFileHttpResponse
{
Content = new(new byte[] { 1, 2, 3 }),
Filename = new("test.bin")
};
}
}

View file

@ -0,0 +1,19 @@
using Elsa.Workflows;
namespace Elsa.Http.IntegrationTests.Activities.Workflows;
/// <summary>
/// A simple workflow that attempts to write an HTTP response without HTTP context.
/// </summary>
internal class WriteHttpResponseWithoutHttpContextWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder builder)
{
builder.Root = new WriteHttpResponse
{
Content = new("This should fail"),
StatusCode = new(System.Net.HttpStatusCode.OK)
};
}
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Include>[Elsa.Http]*</Include>
<Threshold>1</Threshold>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared.Integration\Elsa.Testing.Shared.Integration.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Http\Elsa.Http.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Runtime\Elsa.Workflows.Runtime.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Http;
namespace Elsa.Http.IntegrationTests.Helpers;
/// <summary>
/// A null HTTP context accessor that always returns null.
/// Used for testing scenarios where HTTP context is lost.
/// </summary>
internal class NullHttpContextAccessor : IHttpContextAccessor
{
public HttpContext? HttpContext { get; set; } = null;
}

View file

@ -0,0 +1,80 @@
# Elsa.Http Integration Tests
This test project contains integration tests for HTTP-related activities in Elsa Workflows, specifically focusing on HTTP context loss scenarios.
## Status
**Tests are working!** All tests pass successfully and are properly discovered by xUnit.
## Test Coverage
### HttpContextLossTests
Tests that verify the behavior of HTTP response activities when the HTTP context is lost during workflow execution.
#### Test Scenarios
1. **WriteHttpResponse_WithNoHttpContext_ShouldRecordIncident**
- Verifies that `WriteHttpResponse` activity records an incident when HTTP context is null
- Validates that the incident message clearly explains the HTTP context loss scenario
- **Status**: ✅ Passing
2. **WriteFileHttpResponse_WithNoHttpContext_ShouldRecordIncident**
- Verifies that `WriteFileHttpResponse` activity records an incident when HTTP context is null
- Validates the incident message contains expected information
- **Status**: ✅ Passing
## Expected Behavior
When HTTP context is not available:
- **Fault Code**: `NoHttpContext`
- **Fault Category**: `HTTP`
- **Fault Type**: `System`
- **Error Message**: Detailed explanation including:
- What happened: HTTP context was lost during workflow execution
- Why it happened: Workflow suspended and resumed in different execution context
- Common scenarios: Background processing, virtual actor, workflow transition
- Impact: Original HTTP request context no longer available
- **Result**: An `ActivityIncident` is recorded with the fault message
## Project Structure
```
Elsa.Http.IntegrationTests/
├── Activities/
│ ├── HttpContextLossTests.cs # Test class using WorkflowTestFixture
│ └── Workflows/
│ ├── WriteHttpResponseWithoutHttpContextWorkflow.cs
│ └── WriteFileHttpResponseWithoutHttpContextWorkflow.cs
├── Helpers/
│ └── NullHttpContextAccessor.cs # Mock HTTP context accessor
├── Elsa.Http.IntegrationTests.csproj
├── README.md
└── Usings.cs
```
## Running the Tests
```bash
dotnet test Elsa.Http.IntegrationTests.csproj
```
Or run specific tests:
```bash
dotnet test --filter "FullyQualifiedName~HttpContextLossTests"
```
## Test Results
```
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2
```
## Implementation Notes
- Tests use `WorkflowTestFixture` from `Elsa.Testing.Shared` for consistent test setup
- Workflow classes are separated into individual files in the `Workflows` subfolder for better organization
- `NullHttpContextAccessor` is a test helper that simulates HTTP context loss by always returning null
- Tests automatically build the fixture and populate registries before execution

View file

@ -0,0 +1,2 @@
global using Xunit;

View file

@ -14,6 +14,7 @@ using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Elsa.Workflows.Exceptions;
namespace Elsa.Activities.UnitTests.Http;
@ -213,7 +214,7 @@ public class WriteFileHttpResponseTests
// Assert
Assert.True(context.IsCompleted);
// Range processing and ETag would be handled by FileStreamResult
// Range processing and ETag should be handled by FileStreamResult
Assert.True(httpContext.Response.Headers.ContainsKey("ETag"));
}
@ -279,7 +280,7 @@ public class WriteFileHttpResponseTests
}
[Fact]
public async Task Should_Create_Bookmark_When_No_HttpContext_Available()
public async Task Should_Fault_When_No_HttpContext_Available()
{
// Arrange
var activity = new WriteFileHttpResponse();
@ -292,61 +293,8 @@ public class WriteFileHttpResponseTests
AddMockServices(services);
});
// Act
var context = await fixture.ExecuteAsync();
// Assert
Assert.False(context.IsCompleted);
var bookmarks = context.WorkflowExecutionContext.Bookmarks.ToList();
Assert.Single(bookmarks);
}
[Fact]
public async Task Should_Resume_From_Bookmark_With_HttpContext()
{
// Arrange
var activity = new WriteFileHttpResponse();
var testContent = "Hello World"u8.ToArray();
activity.Content = new(testContent);
var fixture = new ActivityTestFixture(activity);
var httpContext = CreateMockHttpContext();
// First execution - should create bookmark
fixture.ConfigureServices(services =>
{
var mockHttpContextAccessor = Substitute.For<IHttpContextAccessor>();
mockHttpContextAccessor.HttpContext.Returns((HttpContext?)null);
services.AddSingleton(mockHttpContextAccessor);
AddMockServices(services);
});
var firstContext = await fixture.ExecuteAsync();
Assert.False(firstContext.IsCompleted);
// Resume execution with HttpContext available
fixture.ConfigureServices(services =>
{
var newHttpContextAccessor = Substitute.For<IHttpContextAccessor>();
newHttpContextAccessor.HttpContext.Returns(httpContext);
services.AddSingleton(newHttpContextAccessor);
AddMockServices(services);
});
// Act - simulate resume by executing with bookmark context
var newFixture = new ActivityTestFixture(activity);
newFixture.ConfigureServices(services =>
{
var resumeHttpContextAccessor = Substitute.For<IHttpContextAccessor>();
resumeHttpContextAccessor.HttpContext.Returns(httpContext);
services.AddSingleton(resumeHttpContextAccessor);
AddMockServices(services);
});
var resumedContext = await newFixture.ExecuteAsync();
// Assert
Assert.True(resumedContext.IsCompleted);
// Act + Assert
await Assert.ThrowsAsync<FaultException>(() => fixture.ExecuteAsync());
}
[Fact]

View file

@ -5,6 +5,7 @@ using Elsa.Http.Options;
using Elsa.Http.Parsers;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@ -193,7 +194,7 @@ public class WriteHttpResponseTests
}
[Fact]
public async Task Should_Create_Bookmark_When_No_HttpContext_Available()
public async Task Should_Fault_When_No_HttpContext_Available()
{
// Arrange
var activity = new WriteHttpResponse();
@ -208,13 +209,8 @@ public class WriteHttpResponseTests
services.AddSingleton(Substitute.For<IStimulusHasher>());
});
// Act
var context = await fixture.ExecuteAsync();
// Assert
Assert.False(context.IsCompleted);
var bookmarks = context.WorkflowExecutionContext.Bookmarks.ToList();
Assert.Single(bookmarks);
// Act + Assert
await Assert.ThrowsAsync<FaultException>(() => fixture.ExecuteAsync());
}
[Fact]