Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/five-parses-to-reject-one-request && read --section="top" 0%
.NET

Five Parses to Reject One Request

A validated POST is parsed, re-serialised and parsed again on its way to a schema compiled fresh per request - and when it fails validation the caller gets HTTP 200.

By Kumar Chandrachooda 16 Feb 2026 7 min read
The same document redrawn five times, arrows cycling between text form and object form

Client-side error handling is written against status codes. if (!response.ok) in fetch, EnsureSuccessStatusCode() in .NET, retry-on-5xx in every HTTP library ever written. It is the one part of the HTTP contract that every consumer, in every language, agrees on and depends on. So the worst thing an API can do with a rejected request is not to return a confusing error body — it is to return a correct-looking error body under a status code that says everything is fine.

Part 5 followed the payload feature down to the template that never loads. This part follows the other half of the same subsystem: what happens when a payload is validated, how many times the gateway parses it to get there, and what the caller receives when it fails.

Five traversals for one POST

Follow a single validated POST body through the code and count the times it changes representation.

One and two: GetObject parses to learn a type it already knows.

private static object GetObject(string content)
{
    dynamic payload = new ExpandoObject();
    JsonConvert.PopulateObject(content, payload);

    return JsonConvert.DeserializeObject(content, payload.GetType());
}

PayloadTransformer.cs:114-120

PopulateObject parses the string and fills an ExpandoObject. Then payload.GetType() is called on it — and payload was constructed as new ExpandoObject() two lines above, so the answer is typeof(ExpandoObject), statically and unconditionally. Then DeserializeObject parses the same string a second time to produce the object the first parse had already produced. The entire function is JsonConvert.DeserializeObject<ExpandoObject>(content) with an extra full parse in front of it.

Three: the validator re-serialises the object it was handed.

public async Task<IEnumerable<Error>> GetValidationErrorsAsync(PayloadSchema payloadSchema)
{
    if (string.IsNullOrWhiteSpace(payloadSchema.Schema))
    {
        return Enumerable.Empty<Error>();
    }

    return await _schemaValidator.ValidateAsync(JsonConvert.SerializeObject(payloadSchema.Payload),
        payloadSchema.Schema);
}

PayloadValidator.cs:33-42

ISchemaValidator.ValidateAsync takes (string payload, string schema). It is string-typed, so the in-memory object graph — freshly built by two parses — is serialised back to text to be handed to it. NJsonSchema's JsonSchema.Validate has an overload that accepts a parsed JToken; the string one parses again internally.

Four: the schema is compiled per request.

var jsonSchema = await JsonSchema.FromJsonAsync(schema);
var errors = jsonSchema.Validate(payload);

SchemaValidator.cs:17-18

The schema text was read from disk once, at boot, by PayloadManager. The compiled JsonSchema — the expensive artefact, the one with resolved $refs and a built validation tree — is constructed fresh on every request, then thrown away. PayloadSchema is an immutable two-property class holding an ExpandoObject and a string; making it hold a JsonSchema instead, built once at load time, is a two-line change with no behavioural difference. NJsonSchema is thread-safe for validation, which is exactly why the boot-time compile is safe.

Five: the wire serialisation. DownstreamHandler.cs:214 serialises the transformed object one final time to build the outbound StringContent. That one is genuinely necessary.

So: parse, parse, serialise, parse, serialise. Four of the five exist to move a document between two representations that the code already had. None of it is catastrophic — JSON parsing is fast and payloads are small — but it is worth noticing what caused it. Two of the parses come from a string-typed internal interface; one comes from a function asking an object what type it is when the answer is a compile-time literal; one comes from an artefact that is cached at the wrong layer. All four are the same mistake in different clothes: the boundary between “text” and “object” was drawn in more than one place, and every crossing costs a parse.

To be fair to the era and the author: this is a declarative gateway where the shape of a payload is genuinely unknown at compile time, ExpandoObject is the honest .NET representation of “arbitrary JSON object I must be able to mutate by string key”, and System.Text.Json — with its JsonDocument and utf8 readers that would make most of these traversals unnecessary — did not have a usable dynamic story in 2019. Newtonsoft was the correct choice. The cost is in the plumbing, not the library.

And then the status code

Now the ending. Here is what happens when the validator finds errors:

public async Task<bool> TryValidate(ExecutionData executionData, HttpResponse httpResponse)
{
    if (executionData.IsPayloadValid)
    {
        return true;
    }

    var response = new {errors = executionData.ValidationErrors};
    var payload = JsonConvert.SerializeObject(response);
    httpResponse.ContentType = "application/json";
    await httpResponse.WriteAsync(payload);

    return false;
}

PayloadValidator.cs:18-31

Read it looking for the thing that is not there. ContentType is set. The body is written. return false tells the caller to stop. httpResponse.StatusCode is never assigned, and the caller does not assign it either:

if (!executionData.IsPayloadValid)
{
    await _payloadValidator.TryValidate(executionData, context.Response);
    return;
}

DownstreamHandler.cs:76-80

ASP.NET Core's default is 200. So a request that fails JSON Schema validation at the gateway — the wrong type on a field, a missing required property, a string too long — receives:

HTTP/1.1 200 OK
Content-Type: application/json

{"errors":[{"Code":"StringExpected","Property":"customerId","Message":"..."}]}

Every client in the chain will treat that as success. A .NET consumer calling EnsureSuccessStatusCode() sails through and then fails deserialising the errors envelope into its expected DTO. A JavaScript consumer's if (!response.ok) branch never runs. A retry policy sees no transient error. A dashboard counting 4xx at the edge shows a clean graph while every order is being rejected.

There is a second, smaller tell in the same envelope. The {"errors": [...]} object is serialised by a bare JsonConvert.SerializeObject call, so it uses Newtonsoft's default contract resolver and comes out PascalCase — Code, Property, Message. Meanwhile NtradaExtensions.cs:71-73 configures AddNewtonsoftJson(o => o.SerializerSettings.Formatting = Formatting.Indented), which sets up MVC's serialiser — and Ntrada has no controllers and never uses MVC's serialiser for anything. The one serialisation setting in the composition root applies to nothing the gateway emits.

The gate that decides whether any of this runs

Worth noting where the whole subsystem is switched on, because it is a single expression and it explains why most traffic never pays for any of the above:

var skipPayload = route.Use == "downstream" && SkipPayloadMethods.Contains(route.DownstreamMethod);
var routeData = context.GetRouteData();
var hasTransformations = !skipPayload && _payloadTransformer.HasTransformations(resourceId, route);

RequestProcessor.cs:45-47

SkipPayloadMethods is {"get", "delete", "head", "options", "trace"}, and HasTransformations returns true only if a resource id was minted, or the route declares bind:, or it declares transform:, or a payload template is registered for its key. A plain forwarding route with none of those touches none of this code — no body read, no parse, no validation, no schema compile. The expensive path is genuinely opt-in, which makes the per-request costs above much less alarming than they first look.

Two edges in that one line, though. route.Use == "downstream" is a string comparison against a magic literal, so a custom handler registered under any other name never skips the payload path regardless of verb — the transformer runs on its GETs. And route.DownstreamMethod is null on every multi-verb route, as part 2 established, so Contains(null) is false and skipPayload is false: a matchAll route declaring methods: [GET, POST] reads and parses the body of every GET it forwards.

Three error contracts on three paths

The status code is the headline, but it sits inside a broader problem, and the broader problem is the one to take away. Count the ways this gateway can tell a caller that something went wrong:

Path Status Body Content-Type
Auth failure 401 or 403 none none
Payload invalid 200 {"errors":[...]} PascalCase application/json
Downstream error origin's, or onError.code origin's body, verbatim origin's, sometimes

Three shapes, three status conventions, three content-type behaviours. The auth path sets a code and writes nothing — not even a WWW-Authenticate header on the 401, which is the one header a 401 is specified to carry. The downstream path passes the origin's body through untouched, which means the gateway's error format is whatever eleven different services decided independently. The validation path invents a fourth format and forgets the code.

A gateway is the one component in an estate whose error contract must be uniform, because it is the only thing every client talks to. That is not a stylistic preference; it is the entire reason for putting a gateway in front of a fleet. A caller integrating against api.example.com should be able to write one error handler. Against this one they need three, and one of them has to inspect a 200 body to find out whether it was an error.

The fix is small and entirely within the existing design. TryValidate sets httpResponse.StatusCode = 400. The auth path writes the same envelope with a code and a message instead of an empty body. SetErrorResponseAsync is given the option — one more three-state bool?, exactly like the six the DSL already has — to normalise a downstream error into the gateway's own envelope rather than forward it verbatim. Three changes, one contract, and every consumer in the estate gets simpler.

Next, the parser that works because its bug is harmless — two mini-languages, one Split, and a substring that should not be correct.