Summary
When a service operation throws, the fault response is built twice. The error is written to the log twice, IFaultExceptionTransformer.ProvideFault is invoked twice, and the message that IMessageInspector2.BeforeSendReply receives is not the message that is sent to the client.
Code path
ProcessMessage catches the exception, builds a fault response, notifies the message inspectors, and rethrows (src/SoapCore/SoapEndpointMiddleware.cs, tag v1.2.1.15):
catch (Exception ex)
{
responseMessage = CreateErrorResponseMessage(ex, serviceProvider, requestMessage, messageEncoder, httpContext);
correlationObjects2.ForEach(mi => mi.inspector.BeforeSendReply(ref responseMessage, _service, mi.correlationObject));
throw;
}
Because it rethrows, the exception reaches the catch in ProcessOperation, which calls CreateErrorResponseMessage again:
catch (Exception ex)
{
responseMessage = CreateErrorResponseMessage(ex, serviceProvider, requestMessage, messageEncoder, httpContext);
}
That second message is the one written to the response. CreateErrorResponseMessage begins with
_logger.LogError(exception, "An error occurred processing the message");
and calls faultExceptionTransformer.ProvideFault(...).
Observed
Single POST to an operation that throws InvalidOperationException("boom"), SoapCore 1.2.1.15 on net8.0, SoapSerializer.XmlSerializer, MessageVersion.Soap12WSAddressingAugust2004:
|
Observed |
Error entries "An error occurred processing the message" from SoapCore.SoapEndpointMiddleware |
2 |
IFaultExceptionTransformer.ProvideFault invocations |
2 |
IMessageInspector2.AfterReceiveRequest invocations |
1 |
IMessageInspector2.BeforeSendReply invocations |
1 |
Header added by BeforeSendReply present in the fault received by the client |
no |
Expected
One fault response per faulting request: the error logged once, ProvideFault invoked once, and the message handed to BeforeSendReply being the message that is sent.
Affected versions
ProcessMessage in 1.1.0.51 has no catch around the operation invocation, only try/finally. The catch shown above is present in 1.1.0.53, 1.2.0.0, 1.2.1.0 and 1.2.1.15 (each read at its tag). 1.1.0.52 was not checked.
Reproduce
Repro.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SoapCore" Version="1.2.1.15" />
</ItemGroup>
</Project>
Program.cs
using System.ServiceModel;
using System.ServiceModel.Channels;
using SoapCore;
using SoapCore.Extensibility;
using SoapCore.ServiceModel;
[ServiceContract(Namespace = "urn:repro")]
public interface IReproService
{
[OperationContract]
string Boom(string input);
}
public class ReproService : IReproService
{
public string Boom(string input) => throw new InvalidOperationException("boom");
}
public class CountingInspector : IMessageInspector2
{
public static int AfterReceiveCalls;
public static int BeforeSendReplyCalls;
public object AfterReceiveRequest(ref Message message, ServiceDescription serviceDescription)
{
Interlocked.Increment(ref AfterReceiveCalls);
return null;
}
public void BeforeSendReply(ref Message reply, ServiceDescription serviceDescription, object correlationState)
{
Interlocked.Increment(ref BeforeSendReplyCalls);
reply.Headers.Add(MessageHeader.CreateHeader("InspectorStamp", "urn:repro", "was-here"));
}
}
// Only used to count ProvideFault; register before AddSoapCore so the default is not added.
public class CountingFaultTransformer : IFaultExceptionTransformer
{
public static int ProvideFaultCalls;
public Message ProvideFault(Exception exception, MessageVersion messageVersion, Message requestMessage, ConcurrentXmlNamespaceLookup xmlNamespaceLookup)
{
Interlocked.Increment(ref ProvideFaultCalls);
return Message.CreateMessage(messageVersion, action: null);
}
}
public static class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(o => o.SingleLine = true);
if (args.Contains("--count-transformer"))
{
builder.Services.AddSingleton<IFaultExceptionTransformer, CountingFaultTransformer>();
}
builder.Services.AddSoapCore();
builder.Services.AddSingleton<IReproService, ReproService>();
builder.Services.AddSoapMessageInspector<CountingInspector>();
var app = builder.Build();
app.UseRouting();
app.MapGet("/counters", () =>
$"AfterReceiveRequest={CountingInspector.AfterReceiveCalls} "
+ $"BeforeSendReply={CountingInspector.BeforeSendReplyCalls} "
+ $"ProvideFault={CountingFaultTransformer.ProvideFaultCalls}");
((IApplicationBuilder)app).UseSoapEndpoint<IReproService>(
path: "/Service.svc",
new SoapEncoderOptions { MessageVersion = MessageVersion.Soap12WSAddressingAugust2004 },
SoapSerializer.XmlSerializer);
app.Run();
}
}
Request:
POST /Service.svc
Content-Type: application/soap+xml; charset=utf-8; action="urn:repro/IReproService/Boom"
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing">
<s:Header>
<a:Action s:mustUnderstand="1">urn:repro/IReproService/Boom</a:Action>
<a:To s:mustUnderstand="1">http://localhost:5000/Service.svc</a:To>
</s:Header>
<s:Body>
<Boom xmlns="urn:repro"><input>x</input></Boom>
</s:Body>
</s:Envelope>
Run without arguments to see the two log entries, the single BeforeSendReply call and the delivered fault without InspectorStamp. Run with --count-transformer to see ProvideFault=2; that run replaces the fault body, so use the two runs separately.
GET /counters returns the counters.
Summary
When a service operation throws, the fault response is built twice. The error is written to the log twice,
IFaultExceptionTransformer.ProvideFaultis invoked twice, and the message thatIMessageInspector2.BeforeSendReplyreceives is not the message that is sent to the client.Code path
ProcessMessagecatches the exception, builds a fault response, notifies the message inspectors, and rethrows (src/SoapCore/SoapEndpointMiddleware.cs, tagv1.2.1.15):Because it rethrows, the exception reaches the
catchinProcessOperation, which callsCreateErrorResponseMessageagain:That second message is the one written to the response.
CreateErrorResponseMessagebegins withand calls
faultExceptionTransformer.ProvideFault(...).Observed
Single POST to an operation that throws
InvalidOperationException("boom"), SoapCore 1.2.1.15 on net8.0,SoapSerializer.XmlSerializer,MessageVersion.Soap12WSAddressingAugust2004:Errorentries"An error occurred processing the message"fromSoapCore.SoapEndpointMiddlewareIFaultExceptionTransformer.ProvideFaultinvocationsIMessageInspector2.AfterReceiveRequestinvocationsIMessageInspector2.BeforeSendReplyinvocationsBeforeSendReplypresent in the fault received by the clientExpected
One fault response per faulting request: the error logged once,
ProvideFaultinvoked once, and the message handed toBeforeSendReplybeing the message that is sent.Affected versions
ProcessMessagein1.1.0.51has nocatcharound the operation invocation, onlytry/finally. Thecatchshown above is present in1.1.0.53,1.2.0.0,1.2.1.0and1.2.1.15(each read at its tag).1.1.0.52was not checked.Reproduce
Repro.csprojProgram.csRequest:
Run without arguments to see the two log entries, the single
BeforeSendReplycall and the delivered fault withoutInspectorStamp. Run with--count-transformerto seeProvideFault=2; that run replaces the fault body, so use the two runs separately.GET /countersreturns the counters.