Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Ocelot/Errors/OcelotErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ public enum OcelotErrorCode
CouldNotFindLoadBalancerCreator = 39,
ErrorInvokingLoadBalancerCreator = 40,
PayloadTooLargeError = 41,
BadRequestError = 42,
}
12 changes: 12 additions & 0 deletions src/Ocelot/Requester/BadRequestError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Http;
using Ocelot.Errors;

namespace Ocelot.Requester;

public class BadRequestError : Error
{
public BadRequestError(string message)
: base(message, OcelotErrorCode.BadRequestError, StatusCodes.Status400BadRequest)
{
}
}
8 changes: 8 additions & 0 deletions src/Ocelot/Requester/HttpExceptionToErrorMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ public Error Map(Exception exception)
return new PayloadTooLargeError(exception);
}

// Late Catch: Map the HttpRequestException to a 400 Bad Request.
// If the header format is invalid, HttpClient throws this exception locally.
// By catching it here, we ensure Ocelot returns a clean 4xx client error instead of a generic 502 Bad Gateway.
if (exception is HttpRequestException && exception.Message.Contains("only ASCII characters"))
Comment thread
raman-m marked this conversation as resolved.
Outdated
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s hard to judge this solution until a valid acceptance test has been written.

I believe we can improve this area, since the Message.Contains method handles a specific user scenario. But what about codes, internal exceptions, and so on? We should inspect the generated exception and reuse its state rather than relying only on messages.

We might also consider covering more scenarios that return a BadRequestError response.
Given the purpose of BadRequestError, it should cover all exceptions that result in a 400 Bad Request status. So, I'd expect more if-else block(s).
You (we) might be inspired by this #2375 (reply in thread).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate the guidance

{
return new BadRequestError(exception.Message);
}

return new ConnectionToDownstreamServiceError(exception);
}

Expand Down
5 changes: 5 additions & 0 deletions src/Ocelot/Responder/ErrorsToHttpStatusCodeMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ public int Map(List<Error> errors)
return 413;
}

if (errors.Any(e => e.Code == OcelotErrorCode.BadRequestError))
{
return StatusCodes.Status400BadRequest;
}

return 404;
}
}
86 changes: 86 additions & 0 deletions test/Ocelot.AcceptanceTests/Requester/InvalidHeaderValueTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using System.Net.Sockets;
using System.Text;
using Ocelot.Configuration.File;

namespace Ocelot.AcceptanceTests.Requester;

[Trait("Bug", "2376")]
[Trait("PR", "2381")]
public sealed class InvalidHeaderValueTests : Steps
{
private const int BasePortSeed = 20000;
private const int PortStride = 2;
private const int RequestTimeoutSeconds = 10;
private const int ReadBufferSize = 4096;
private const string GatewayRequestPath = "/ocelot/posts/askdj";
private const string DownstreamRequestPath = "/todos/askdj";
private const string DownstreamResponseBody = "Hello from Laura";
private const string HostHeaderName = "Host";
private const string AcceptHeaderName = "Accept";
private const string ConnectionHeaderName = "Connection";
private const string TestHeaderName = "skull";
private const string ExpectedStatusLine = "HTTP/1.1 400 Bad Request";

[Theory]
[InlineData("💀")]
[InlineData("é")]
[InlineData("漢")]
public async Task Should_return_400_bad_request_when_request_contains_non_ascii_header_value(string headerValue)
Comment thread
raman-m marked this conversation as resolved.
Outdated
{
var basePort = BasePortSeed + (Environment.ProcessId % 10000) * PortStride;
var downstreamPort = basePort;
var gatewayPort = basePort + 1;
Comment thread
Majdi-Zlitni marked this conversation as resolved.
Outdated
var route = GivenRoute(downstreamPort, "/ocelot/posts/{id}", "/todos/{id}");
var configuration = GivenConfiguration(route);

GivenThereIsAConfiguration(configuration);
GivenThereIsAServiceRunningOn(downstreamPort, DownstreamRequestPath, context =>
{
context.Response.StatusCode = (int)HttpStatusCode.OK;
return context.Response.WriteAsync(DownstreamResponseBody);
});
Comment thread
Majdi-Zlitni marked this conversation as resolved.
Outdated
await GivenOcelotHostIsRunning(null, null, null, builder => builder
.UseKestrel()
.ConfigureAppConfiguration(WithBasicConfiguration)
.ConfigureServices(WithAddOcelot)
.Configure(WithUseOcelot)
.UseUrls(DownstreamUrl(gatewayPort)), null, null, null);
Comment thread
Majdi-Zlitni marked this conversation as resolved.
Outdated

var response = await SendRawRequestAsync(gatewayPort, headerValue);
Comment thread
raman-m marked this conversation as resolved.
Outdated

response.FirstLine().ShouldBe(ExpectedStatusLine);
}

private static async Task<string> SendRawRequestAsync(int port, string headerValue)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(RequestTimeoutSeconds));
using var client = new TcpClient();
await client.ConnectAsync(System.Net.IPAddress.Loopback, port).WaitAsync(timeout.Token);

using var stream = client.GetStream();
Comment thread
raman-m marked this conversation as resolved.
Outdated
var request = $"GET {GatewayRequestPath} HTTP/1.1\r\n{HostHeaderName}: localhost:{port}\r\n{AcceptHeaderName}: */*\r\n{TestHeaderName}: {headerValue}\r\n{ConnectionHeaderName}: close\r\n\r\n";
Comment thread
raman-m marked this conversation as resolved.
Outdated
var requestBytes = Encoding.UTF8.GetBytes(request);
await stream.WriteAsync(requestBytes, timeout.Token);
await stream.FlushAsync(timeout.Token);

var buffer = new byte[ReadBufferSize];
var response = new StringBuilder();
int read;

while ((read = await stream.ReadAsync(buffer, timeout.Token)) > 0)
{
response.Append(Encoding.UTF8.GetString(buffer, 0, read));
}
Comment on lines +36 to +39
Copy link
Copy Markdown
Member

@raman-m raman-m Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that is funny.
This life hack — which catches the HttpClient exception early by adjusting the response to return a 400 status — actually triggers the exception too early. Here is the call stack it produces:

   at System.Net.Http.HttpConnection.<WriteString>g__ThrowForInvalidCharEncoding|55_0()
   at System.Net.Http.HttpConnection.WriteString(String s, Encoding encoding)
   at System.Net.Http.HttpConnection.WriteHeaderCollection(HttpHeaders headers, String cookiesFromContainer)
   at System.Net.Http.HttpConnection.WriteHeaders(HttpRequestMessage request)
   at System.Net.Http.HttpConnection.<SendAsync>d__56.MoveNext()
   at System.Net.Http.HttpConnection.<SendAsync>d__56.MoveNext()
   at System.Net.Http.HttpConnectionPool.<SendWithVersionDetectionAndRetryAsync>d__52.MoveNext()
   at System.Net.Http.DiagnosticsHandler.<SendAsyncCore>d__10.MoveNext()
   at System.Runtime.CompilerServices.ConfiguredValueTaskAwaitable`1.ConfiguredValueTaskAwaiter.GetResult()
   at System.Net.Http.RedirectHandler.<SendAsync>d__4.MoveNext()
   at System.Net.Http.SocketsHttpHandler.<<SendAsync>g__CreateHandlerAndSendAsync|115_0>d.MoveNext()
   at System.Net.Http.HttpClient.<<SendAsync>g__Core|83_0>d.MoveNext()
   at Ocelot.Testing.AcceptanceSteps.<WhenIGetUrlOnTheApiGateway>d__86.MoveNext() in D:\github\Ocelot\Majdi-Zlitni\Ocelot\testing\AcceptanceSteps.cs:line 368
   at Ocelot.AcceptanceTests.Requester.InvalidHeaderValueTests.<WhenIGetUrlOnTheApiGatewaySafely>d__1.MoveNext() in D:\github\Ocelot\Majdi-Zlitni\Ocelot\test\Ocelot.AcceptanceTests\Requester\InvalidHeaderValueTests.cs:line 34

However, the original bug #2374 has a different call stack:

         at System.Net.Http.HttpConnection.<WriteString>g__ThrowForInvalidCharEncoding|55_0()
         at System.Net.Http.HttpConnection.WriteString(String s, Encoding encoding)
         at System.Net.Http.HttpConnection.WriteHeaderCollection(HttpHeaders headers, String cookiesFromContainer)
         at System.Net.Http.HttpConnection.WriteHeaders(HttpRequestMessage request)
         at System.Net.Http.HttpConnection.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
         at System.Net.Http.HttpConnection.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
         at System.Net.Http.HttpConnectionPool.SendWithVersionDetectionAndRetryAsync(HttpRequestMessage request, Boolean async, Boolean doRequestAuth, CancellationToken cancellationToken)
         at System.Net.Http.DiagnosticsHandler.SendAsyncCore(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
         at Ocelot.Requester.TimeoutDelegatingHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) in /home/laura/scratch_pad/Ocelot/src/Ocelot/Requester/TimeoutDelegatingHandler.cs:line 30
         at Ocelot.Requester.MessageInvokerHttpRequester.GetResponse(HttpContext httpContext) in /home/laura/scratch_pad/Ocelot/src/Ocelot/Requester/MessageInvokerHttpRequester.cs:line 29

As a result, we are reproducing the same bug on the HttpClient side, and the actual request never reaches the Ocelot side. This happened because the acceptance test was changed when I asked to use our testing helpers.

I was wrong — the original test version actually used the correct approach to emulate a curl request, based on TcpClient streams.

Tomorrow I will rewrite the test based on the original version. The AI coding agent was right when it suggested how to properly emulate curl requests in .NET apps.
In the end, we must use plain TcpClient streams only if we want to reliably reproduce the bug.
This is my fault 🙃


return response.ToString();
}
}

internal static class InvalidHeaderValueTestsExtensions
{
public static string FirstLine(this string response)
=> response.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)[0];
}
Comment thread
raman-m marked this conversation as resolved.
Outdated
123 changes: 123 additions & 0 deletions test/Ocelot.UnitTests/Kubernetes/PollKubeTests.cs
Comment thread
raman-m marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,127 @@ public async Task GetAsync()
Assert.Same(latestVersion, actual);
_discoveryProvider.Verify(x => x.GetAsync(), Times.Never);
}

[Fact]
[Trait("Bug", "2304")]
public async Task GetAsync_WhenDisposed_ReturnsEmpty()
{
// Arrange
_provider = new PollKube(10_000, _factory.Object, _discoveryProvider.Object);
_provider.Dispose();

// Act
var actual = await _provider.GetAsync();

// Assert
Assert.Same(PollKube.Empty, actual);
}

[Fact]
[Trait("Bug", "2304")]
public async Task PollAsync_WhenQueueCountGreaterThan3_ReturnsEmpty()
{
// Arrange
_provider = new PollKube(10_000, _factory.Object, _discoveryProvider.Object);
var method = _provider.GetType().GetMethod("PollAsync", BindingFlags.Instance | BindingFlags.NonPublic);
var queueField = _provider.GetType().GetField("_queue", BindingFlags.Instance | BindingFlags.NonPublic);
var queue = (ConcurrentQueue<List<Service>>)queueField.GetValue(_provider);
for (int i = 0; i < 4; i++)
{
queue.Enqueue(new List<Service>());
}

// Act
var task = (Task<List<Service>>)method.Invoke(_provider, [CancellationToken.None]);
var actual = await task;

// Assert
Assert.Same(PollKube.Empty, actual);
_discoveryProvider.Verify(x => x.GetAsync(), Times.Never);
}

[Fact]
[Trait("Bug", "2304")]
public async Task PollAsync_WhenObjectDisposedExceptionThrown_ReturnsEmpty()
{
// Arrange
_discoveryProvider.Setup(x => x.GetAsync()).ThrowsAsync(new ObjectDisposedException("provider"));
_provider = new PollKube(10_000, _factory.Object, _discoveryProvider.Object);
var method = _provider.GetType().GetMethod("PollAsync", BindingFlags.Instance | BindingFlags.NonPublic);

// Act
var task = (Task<List<Service>>)method.Invoke(_provider, [CancellationToken.None]);
var actual = await task;

// Assert
Assert.Same(PollKube.Empty, actual);
}

[Fact]
[Trait("Bug", "2304")]
public async Task PollAsync_WhenCancelled_ReturnsEmpty()
{
// Arrange
_provider = new PollKube(10_000, _factory.Object, _discoveryProvider.Object);
var method = _provider.GetType().GetMethod("PollAsync", BindingFlags.Instance | BindingFlags.NonPublic);
var cts = new CancellationTokenSource();
cts.Cancel();

// Act
var task = (Task<List<Service>>)method.Invoke(_provider, [cts.Token]);
var actual = await task;

// Assert
Assert.Same(PollKube.Empty, actual);
_discoveryProvider.Verify(x => x.GetAsync(), Times.Never);
}

[Fact]
[Trait("Bug", "2304")]
public async Task PollAsync_WhenCancelledDuringWait_ReturnsEmpty()
{
// Arrange
var tcs = new TaskCompletionSource<List<Service>>();
_discoveryProvider.Setup(x => x.GetAsync()).Returns(tcs.Task);
_provider = new PollKube(10_000, _factory.Object, _discoveryProvider.Object);
var method = _provider.GetType().GetMethod("PollAsync", BindingFlags.Instance | BindingFlags.NonPublic);
var innerCts = new CancellationTokenSource();

// Act
var task = (Task<List<Service>>)method.Invoke(_provider, [innerCts.Token]);
innerCts.Cancel();
tcs.SetResult(new List<Service>());
var actual = await task;

// Assert
Assert.Same(PollKube.Empty, actual);
}

[Fact]
public void Finalizer_DoesNotThrow()
{
var instance = new PollKube(10_000, _factory.Object, _discoveryProvider.Object);
var method = instance.GetType().GetMethod("Dispose", BindingFlags.Instance | BindingFlags.NonPublic);
method.Invoke(instance, [false]);
}

[Fact]
[Trait("Bug", "2304")]
public async Task StartAsync_WhenProviderDisposed_CatchesOperationCanceledException()
{
// Arrange
_discoveryProvider.Setup(x => x.GetAsync()).ReturnsAsync(new List<Service>());
_provider = new PollKube(10_000, _factory.Object, _discoveryProvider.Object);
var method = _provider.GetType().GetMethod("StartAsync", BindingFlags.Instance | BindingFlags.NonPublic);
var ctsField = _provider.GetType().GetField("_cts", BindingFlags.Instance | BindingFlags.NonPublic);
var cts = (CancellationTokenSource)ctsField.GetValue(_provider);

// Act
var task = (Task)method.Invoke(_provider, null);
cts.Cancel(); // This will trigger OperationCanceledException in StartAsync loop
await task; // Should not throw

// Assert
Assert.True(task.IsCompletedSuccessfully); // Task ended without bubbling up the exception
}
}
23 changes: 23 additions & 0 deletions test/Ocelot.UnitTests/Requester/BadRequestErrorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Http;
using Ocelot.Errors;
using Ocelot.Requester;

namespace Ocelot.UnitTests.Requester;

public class BadRequestErrorTests
{
[Fact]
public void Should_create_bad_request_error()
{
// Arrange
var message = "This is a bad request message.";

// Act
var error = new BadRequestError(message);

// Assert
error.Message.ShouldBe(message);
error.Code.ShouldBe(OcelotErrorCode.BadRequestError);
error.HttpStatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
}
15 changes: 15 additions & 0 deletions test/Ocelot.UnitTests/Requester/HttpExceptionToErrorMapperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ public void Should_return_ConnectionToDownstreamServiceError()
error.ShouldBeOfType<ConnectionToDownstreamServiceError>();
}

[Fact]
public void Map_HttpRequestException_With_NonASCII_To_BadRequestError()
{
// Arrange
var ex = new HttpRequestException("Some header contains only ASCII characters but it doesn't.");

// Act
var error = _mapper.Map(ex);

// Assert
Assert.IsType<BadRequestError>(error);
Assert.Equal(42, (int)error.Code);
Assert.Equal(400, error.HttpStatusCode);
}

private class SomeException : OperationCanceledException { }

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ public void Should_return_bad_gateway_error(OcelotErrorCode errorCode)
ShouldMapErrorToStatusCode(errorCode, HttpStatusCode.BadGateway);
}

[Theory]
[InlineData(OcelotErrorCode.BadRequestError)]
public void Should_return_bad_request(OcelotErrorCode errorCode)
{
ShouldMapErrorToStatusCode(errorCode, HttpStatusCode.BadRequest);
}

[Theory]
[InlineData(OcelotErrorCode.CannotAddDataError)]
[InlineData(OcelotErrorCode.CannotFindDataError)]
Expand All @@ -73,15 +80,15 @@ public void Should_return_bad_gateway_error(OcelotErrorCode errorCode)
public void Should_return_not_found(OcelotErrorCode errorCode)
{
ShouldMapErrorToStatusCode(errorCode, HttpStatusCode.NotFound);
}
}

[Fact]
[Trait("Bug", "749")] // https://github.com/ThreeMammals/Ocelot/issues/749
[Trait("PR", "1769")] // https://github.com/ThreeMammals/Ocelot/pull/1769
public void Should_return_request_entity_too_large()
{
ShouldMapErrorsToStatusCode(new() { OcelotErrorCode.PayloadTooLargeError }, HttpStatusCode.RequestEntityTooLarge);
}
[Trait("PR", "1769")] // https://github.com/ThreeMammals/Ocelot/pull/1769
public void Should_return_request_entity_too_large()
{
ShouldMapErrorsToStatusCode(new() { OcelotErrorCode.PayloadTooLargeError }, HttpStatusCode.RequestEntityTooLarge);
}
Comment thread
raman-m marked this conversation as resolved.
Outdated

[Fact]
public void AuthenticationErrorsHaveHighestPriority()
Expand Down Expand Up @@ -128,7 +135,7 @@ public void Check_we_have_considered_all_errors_in_these_tests()
// If this test fails then it's because the number of error codes has changed.
// You should make the appropriate changes to the test cases here to ensure
// they cover all the error codes, and then modify this assertion.
Enum.GetNames<OcelotErrorCode>().Length.ShouldBe(42, "Looks like the number of error codes has changed. Do you need to modify ErrorsToHttpStatusCodeMapper?");
Enum.GetNames<OcelotErrorCode>().Length.ShouldBe(43, "Looks like the number of error codes has changed. Do you need to modify ErrorsToHttpStatusCodeMapper?");
}

private void ShouldMapErrorToStatusCode(OcelotErrorCode errorCode, HttpStatusCode expectedHttpStatusCode)
Expand Down
Loading