Tratar JSON malformado no .NET com System.Text.Json

O que o ReadFromJsonAsync faz com JSON quebrado e esquisito, quais casos não lançam exceção e um leitor que transforma cada um num erro claro.

Exemplos executados pela última vez contra a API em

Uma API pode responder 200 OK com um corpo que não é JSON válido: uma resposta cortada por um proxy, um corpo vazio, uma página de erro HTML, um valor do tipo errado. Como o seu código .NET reage decide se os usuários veem um erro claro ou uma quebra com uma JsonException escondida num log. Para testar isso, você precisa de respostas quebradas de propósito. https://api.testingapis.com tem um conjunto delas; são para membros conectados, e gratuitas. Este guia mostra exatamente o que System.Text.Json e ReadFromJsonAsync fazem com cada uma, os casos que não lançam exceção mas deveriam, um leitor seguro e testes xUnit. Tudo rodou no .NET 10.

Terminal
$ curl -i https://api.testingapis.com/api/formats/malformed-json
HTTP/1.1 200 OK
content-length: 61
content-type: application/json; charset=utf-8

{"id": 1, "name": "Alice", "tags": ["a", "b",], "active": tru
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

Há duas coisas erradas nesses 61 bytes: uma vírgula sobrando depois de "b" e um corpo cortado no meio de true. O status continua 200 e o tipo de conteúdo continua dizendo JSON.

JSON quebrado e esquisito quando quiser

RequisiçãoO que volta
GET /api/formats/malformed-jsonentre para ver200, application/json, JSON inválido e truncado
GET /api/formats/emptyentre para ver200 com corpo vazio
GET /api/formats/no-contententre para ver204 No Content
GET /api/formats/wrong-content-typeentre para verJSON válido rotulado como text/html
GET /api/formats/bomentre para verJSON válido com uma marca de ordem de bytes UTF-8 na frente
GET /api/edge-cases/duplicate-keysentre para verJSON válido com a mesma chave duas vezes
GET /api/edge-cases/numbersentre para verLimites de int64, números além da faixa segura do JavaScript, um inteiro de 30 dígitos, números como strings
GET /api/edge-cases/top-level/stringentre para verUma string JSON onde se espera um objeto
GET /api/edge-cases/mixed-arrayentre para verUm array com números, strings, booleanos, null, um objeto e um array

O que o ReadFromJsonAsync faz com cada uma

Cada resposta foi lida num record simples. JSON inválido lança uma JsonException cujo Path aponta o problema; três dos casos não lançam nada:

Program.cs
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };

async Task Read<T>(string path, JsonSerializerOptions? options = null)
{
    try
    {
        using var response = await http.GetAsync(path);
        var value = await response.Content.ReadFromJsonAsync<T>(options);
        Console.WriteLine($"{path,-38} OK  {JsonSerializer.Serialize(value)}");
    }
    catch (JsonException ex)
    {
        Console.WriteLine($"{path,-38} JsonException at {ex.Path}: {ex.Message[..ex.Message.IndexOf(". ")]}");
    }
}

await Read<Person>("/api/formats/malformed-json");
await Read<Person>("/api/formats/empty");
await Read<Person>("/api/formats/no-content");
await Read<Person>("/api/formats/wrong-content-type");
await Read<Person>("/api/formats/bom");
await Read<Person>("/api/edge-cases/duplicate-keys");
await Read<Person>("/api/edge-cases/top-level/string");
await Read<List<int>>("/api/edge-cases/mixed-array");

record Person(int Id, string Name);
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita
Saída
/api/formats/malformed-json            JsonException at $.tags: The JSON array contains a trailing comma at the end which is not supported in this mode
/api/formats/empty                     JsonException at $: The input does not contain any JSON tokens
/api/formats/no-content                JsonException at $: The input does not contain any JSON tokens
/api/formats/wrong-content-type        OK  {"Id":1,"Name":"Alice Johnson"}
/api/formats/bom                       OK  {"Id":1,"Name":"Alice Johnson"}
/api/edge-cases/duplicate-keys         OK  {"Id":2,"Name":"second value"}
/api/edge-cases/top-level/string       JsonException at $: The JSON value could not be converted to Person
/api/edge-cases/mixed-array            JsonException at $[1]: The JSON value could not be converted to System.Int32
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita
RespostaResultadoPor que importa
JSON truncado ou inválidoJsonException, com Path, LineNumber e BytePositionInLineCapture e informe qual chamada quebrou, ou ela aparece como uma quebra sem explicação
200 vazio, ou 204JsonException: nenhum token JSONConfira se o corpo está vazio antes de ler, quando a API puder mandar um
JSON rotulado como text/htmlLido sem reclamarO ReadFromJsonAsync não confere o Content-Type. Uma página de erro HTML de verdade falha depois, como JSON inválido
Marca de ordem de bytesLidoTratado para você
A mesma chave duas vezesLido: o último valor vence, em silêncioid virou 2 e name "second value". Transforme em erro com AllowDuplicateProperties = false (.NET 10)
Formato errado (uma string, um array misto)JsonException no primeiro valor ruimO Path diz onde: $ para a raiz, $[1] para o segundo elemento

Números

O C# lida com inteiros grandes melhor que o JavaScript: um long guarda 9007199254740993 exatamente, enquanto o JavaScript arredonda. Mas um inteiro de 30 dígitos não cabe num long, e números enviados como strings dependem das suas opções. O ReadFromJsonAsync usa os padrões web, que os aceitam; opções sem NumberHandling.AllowReadingFromString não aceitam.

Program.cs
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
using var response = await http.GetAsync("/api/edge-cases/numbers");
var json = await response.Content.ReadAsStringAsync();

Console.WriteLine(JsonSerializer.Serialize(
    JsonSerializer.Deserialize<Numbers>(json, JsonSerializerOptions.Web)));

// The same field, read with the web defaults and without them.
var strict = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
foreach (var (label, options) in new[] { ("Web defaults", JsonSerializerOptions.Web), ("Without web defaults", strict) })
{
    try
    {
        var value = JsonSerializer.Deserialize<AsString>(json, options);
        Console.WriteLine($"{label}: \"9223372036854775807\" read as {value!.Int64MaxAsString}");
    }
    catch (JsonException ex) { Console.WriteLine($"{label}: JsonException at {ex.Path}"); }
}

try { JsonSerializer.Deserialize<TooBig>(json, JsonSerializerOptions.Web); }
catch (JsonException ex) { Console.WriteLine($"bigInteger into long: JsonException at {ex.Path}"); }

record Numbers(long Int64Max, long BeyondSafeInteger, double PointOnePlusPointTwo);
record AsString(long Int64MaxAsString);
record TooBig(long BigInteger);
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita
Saída
{"Int64Max":9223372036854775807,"BeyondSafeInteger":9007199254740993,"PointOnePlusPointTwo":0.30000000000000004}
Web defaults: "9223372036854775807" read as 9223372036854775807
Without web defaults: JsonException at $.int64MaxAsString
bigInteger into long: JsonException at $.bigInteger

Um leitor seguro: um erro claro para cada corpo ruim

Leia o corpo primeiro como string, para que o erro possa citá-lo, e depois faça o parse. Toda falha vira uma InvalidDataException com a URL, o tipo de conteúdo, onde o parse parou e o começo do corpo. É disso que você precisa num log às 3 da manhã.

JsonResponses.cs
using System.Text.Json;

public static class JsonResponses
{
    // Web defaults (camelCase, case-insensitive, numbers in strings allowed), but a repeated
    // property is an error instead of the last value silently winning (.NET 10).
    private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
    {
        AllowDuplicateProperties = false,
    };

    /// Reads a JSON body, or throws an InvalidDataException that says what came back and where it broke.
    public static async Task<T> ReadJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
    {
        response.EnsureSuccessStatusCode();
        var url = response.RequestMessage?.RequestUri;
        var contentType = response.Content.Headers.ContentType?.ToString() ?? "no content type";
        var body = await response.Content.ReadAsStringAsync(cancellationToken);

        if (string.IsNullOrWhiteSpace(body))
        {
            throw new InvalidDataException($"{(int)response.StatusCode} from {url} had an empty body ({contentType}).");
        }
        try
        {
            return JsonSerializer.Deserialize<T>(body, Options)
                ?? throw new InvalidDataException($"{url} returned JSON null.");
        }
        catch (JsonException ex)
        {
            var preview = body.Length > 120 ? body[..120] + "..." : body;
            throw new InvalidDataException(
                $"Invalid JSON from {url} ({contentType}) at {ex.Path}, line {ex.LineNumber}, byte {ex.BytePositionInLine}: {preview}", ex);
        }
    }
}
Program.cs
using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };

foreach (var path in new[] { "/api/Products/1", "/api/formats/malformed-json", "/api/formats/empty", "/api/edge-cases/duplicate-keys" })
{
    try
    {
        using var response = await http.GetAsync(path);
        var item = await response.ReadJsonAsync<Item>();
        Console.WriteLine($"OK: {item}");
    }
    catch (InvalidDataException ex)
    {
        Console.WriteLine(ex.Message);
    }
}

record Item(int Id);
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita
Saída
OK: Item { Id = 1 }
Invalid JSON from https://api.testingapis.com/api/formats/malformed-json (application/json; charset=utf-8) at $.tags, line 0, byte 45: {"id": 1, "name": "Alice", "tags": ["a", "b",], "active": tru
200 from https://api.testingapis.com/api/formats/empty had an empty body (no content type).
Invalid JSON from https://api.testingapis.com/api/edge-cases/duplicate-keys (application/json; charset=utf-8) at $.id, line 5, byte 7: {
  "id": 1,
  "name": "first value",
  "name": "second value",
  "nested": { "flag": true, "flag": false },
  "id": 2
}
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

A duplicata é apontada em $.id, linha 5: Item só tem um Id, então essa é a propriedade repetida que importa para ele. Chaves que o tipo não mapeia são ignoradas de qualquer jeito.

Testes xUnit

Uma theory cobre todas as respostas quebradas; um fact confere que JSON bom continua sendo lido. Passam com xUnit 2.9 no .NET 10, usando a classe JsonResponses acima.

JsonResponseTests.cs
using Xunit;

public class JsonResponseTests
{
    private static readonly HttpClient Http = new() { BaseAddress = new Uri("https://api.testingapis.com") };

    private record Item(int Id);

    [Theory]
    [InlineData("/api/formats/malformed-json")]
    [InlineData("/api/formats/empty")]
    [InlineData("/api/edge-cases/duplicate-keys")]
    [InlineData("/api/edge-cases/top-level/string")]
    public async Task Bad_json_becomes_one_clear_error(string path)
    {
        using var response = await Http.GetAsync(path);
        var ex = await Assert.ThrowsAsync<InvalidDataException>(() => response.ReadJsonAsync<Item>());
        Assert.Contains(path, ex.Message); // the message says which call broke
    }

    [Fact]
    public async Task Good_json_still_reads()
    {
        using var response = await Http.GetAsync("/api/Products/1");
        var item = await response.ReadJsonAsync<Item>();
        Assert.Equal(1, item.Id);
    }
}
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

O que verificar no seu cliente

  • JSON inválido com status 200 é um erro, não uma quebra: capturado, registrado com a URL, mostrado ao usuário.
  • Um corpo vazio ou um 204 é tratado antes do parse, onde a API puder mandá-los.
  • Chaves duplicadas são rejeitadas onde um "o último vence" silencioso esconderia um bug.
  • IDs grandes cabem no tipo: long, ou string quando podem passar dele.
  • Os erros citam o suficiente do corpo para diagnosticar, sem registrar segredos.

As mesmas respostas ajudam em outras linguagens: a referência de casos extremos de JSON e a referência de formatos listam todas, e erros quando quiser cobre os códigos de status de falha.