When building modern applications with microservices architecture, one common challenge is ensuring consistent error handling across different services. Sometimes, upstream services return “successful” HTTP 200 responses containing validation errors in the response body - a pattern that can make error handling in client applications unnecessarily complex.
Today, I’ll show you how to use YARP (Yet Another Reverse Proxy) to intercept these responses and transform them into more appropriate HTTP status codes. Specifically, we’ll look at a real-world example where we convert a 200 response containing validation errors into a 422 Unprocessable Entity response, making it more semantically correct and easier for clients to handle.
Note: This example works with YARP version 2.10 and later, which introduces improved response transformation capabilities. You can find the complete working example in my GitHub repository.
YARP is a reverse proxy library developed by Microsoft that provides powerful capabilities for routing, load balancing, and transforming requests and responses. One of its key features is the ability to intercept and modify responses from upstream services before they reach the client.
Response transforms in YARP allow you to:
These transforms are applied through a pipeline, making it easy to chain multiple transformations together.
Consider this scenario: your backend service performs validation on submitted data and returns a JSON response like this:
{
"success": false,
"errors": ["Invalid first name", "Invalid last name"]
}
The problem? This response comes with a 200 OK status code, even though it clearly represents a validation failure. According to HTTP semantics, a more appropriate status code would be 422 Unprocessable Entity.
Let’s look at how we can create a response transform to detect this pattern and convert it to a proper 422 response. We’ll utilize YARP’s powerful response transformation capabilities introduced in version 2.10 and later.
All the code shown in this post is available in my GitHub repository as a fully functional example.
First, let’s create a class that represents the validation response structure we expect to receive:
namespace YourApplication.Features.ApiTransformations;
/// <summary>
/// Represents the response from the validation endpoint
/// </summary>
public class ValidationResponse
{
/// <summary>
/// Indicates whether the validation was successful
/// </summary>
public bool Success { get; set; }
/// <summary>
/// List of error messages, only populated when Success is false
/// </summary>
public string[]? Errors { get; set; }
}
Next, let’s create a transform provider that will be registered with YARP:
using System.Text;
using System.Text.Json;
using Yarp.ReverseProxy.Transforms;
using Yarp.ReverseProxy.Transforms.Builder;
namespace YourApplication.Features.ApiTransformations;
internal static class ValidationErrorTransformer
{
public static void Transform(TransformBuilderContext builder) =>
builder.AddResponseTransform(async context =>
{
HttpContext httpContext = context.HttpContext;
HttpResponse response = httpContext.Response;
// Only process responses with 200 status code
if (response.StatusCode != 200)
{
return;
}
try
{
// Get the response content as a string
string responseBody = string.Empty;
if (context.ProxyResponse is not null)
{
responseBody = await context.ProxyResponse.Content.ReadAsStringAsync();
// Try to deserialize the response directly into our expected format
try
{
ValidationResponse? validationResponse = JsonSerializer.Deserialize<ValidationResponse>(
responseBody,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
);
// Check if this is a validation error response
if (validationResponse is { Success: false, Errors.Length: > 0 })
{
// Change status code to 422 Unprocessable Entity
response.StatusCode = 422;
// Replace the response body with only the array of errors
context.SuppressResponseBody = true;
// Serialize just the errors array
string errorsJson = JsonSerializer.Serialize(validationResponse.Errors,
new JsonSerializerOptions
{
WriteIndented = true
});
// Set content type and write the new response
response.ContentType = "application/json";
response.ContentLength = Encoding.UTF8.GetByteCount(errorsJson);
await response.WriteAsync(errorsJson);
}
}
catch (JsonException)
{
// If we can't deserialize to our expected format, it's not a validation error response
}
}
}
catch (Exception)
{
// In case of errors, return a 500 Internal Server Error
response.StatusCode = 500;
}
});
}
Next, we register this transform in our application’s startup code:
public class Program
{
public static void Main(string[] args)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Add YARP reverse proxy services
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(transformBuilder =>
{
// Add our validation error transformer
ValidationErrorTransformer.Transform(transformBuilder);
});
WebApplication app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
// Map the reverse proxy endpoints
app.MapReverseProxy();
app.Run();
}
}
Finally, configure your YARP routes in appsettings.json
to use this transform:
{
"ReverseProxy": {
"Routes": {
"api": {
"ClusterId": "apiCluster",
"Match": {
"Path": "/api/{**catch-all}"
}
}
},
"Clusters": {
"apiCluster": {
"Destinations": {
"destination1": {
"Address": "https://your-upstream-api.com/"
}
}
}
}
}
}
Let’s break down how this transform works:
context.ProxyResponse.Content
, which is available in YARP 2.10+.ValidationResponse
model with case-insensitive property matching.validationResponse is { Success: false, Errors.Length: > 0 }
), we determine if it’s a validation error.The key improvement in this version is the direct access to context.ProxyResponse.Content
, which eliminates the need for manual body manipulation used in older versions of YARP.
For a fully working implementation with tests, check out my GitHub repository.
Reading and transforming response bodies can impact performance, so consider the following:
The example includes basic error handling, but in a production system you might want to:
Our example assumes JSON responses. For other content types, you’ll need different parsing strategies.
You might want to apply this transform only for certain routes or endpoints. You can modify the transform to check the request path or add route-specific configuration:
// Only transform responses from certain paths
if (!httpContext.Request.Path.StartsWithSegments("/api/users"))
{
return;
}
YARP allows you to chain multiple transforms. You might want to combine this with:
For more complex scenarios, you could load transformation rules from configuration or a database:
// Example: Load transformation rules from configuration
var transformRules = httpContext.RequestServices.GetRequiredService<IOptions<TransformRules>>().Value;
if (!transformRules.EnableValidationTransform)
{
return;
}
YARP’s response transforms provide a powerful way to ensure consistent error handling across your microservices architecture. By intercepting and modifying responses as demonstrated, you can create a more consistent API experience that follows HTTP semantics and makes error handling easier for client applications.
The specific example we explored - transforming a 200 response with validation errors into a 422 status code - is just one of many possibilities. The same approach can be used for various scenarios where upstream services don’t follow your desired API conventions.
Remember that while response transformation is powerful, it’s also important to consider performance implications, especially for high-traffic applications. When possible, working with upstream service teams to standardize error handling is often the best long-term solution.
For a complete implementation of this example and more, visit my GitHub repository.
Have you used YARP for other interesting response transformations? Let me know in the comments!