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.
$ 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": truHá 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ção | O que volta |
|---|---|
GET /api/formats/malformed-jsonentre para ver | 200, application/json, JSON inválido e truncado |
GET /api/formats/emptyentre para ver | 200 com corpo vazio |
GET /api/formats/no-contententre para ver | 204 No Content |
GET /api/formats/wrong-content-typeentre para ver | JSON válido rotulado como text/html |
GET /api/formats/bomentre para ver | JSON válido com uma marca de ordem de bytes UTF-8 na frente |
GET /api/edge-cases/duplicate-keysentre para ver | JSON válido com a mesma chave duas vezes |
GET /api/edge-cases/numbersentre para ver | Limites 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 ver | Uma string JSON onde se espera um objeto |
GET /api/edge-cases/mixed-arrayentre para ver | Um 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:
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);/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| Resposta | Resultado | Por que importa |
|---|---|---|
| JSON truncado ou inválido | JsonException, com Path, LineNumber e BytePositionInLine | Capture e informe qual chamada quebrou, ou ela aparece como uma quebra sem explicação |
| 200 vazio, ou 204 | JsonException: nenhum token JSON | Confira se o corpo está vazio antes de ler, quando a API puder mandar um |
| JSON rotulado como text/html | Lido sem reclamar | O 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 bytes | Lido | Tratado para você |
| A mesma chave duas vezes | Lido: o último valor vence, em silêncio | id 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 ruim | O 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.
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);{"Int64Max":9223372036854775807,"BeyondSafeInteger":9007199254740993,"PointOnePlusPointTwo":0.30000000000000004}
Web defaults: "9223372036854775807" read as 9223372036854775807
Without web defaults: JsonException at $.int64MaxAsString
bigInteger into long: JsonException at $.bigIntegerUm 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ã.
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);
}
}
}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);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
}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.
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);
}
}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, oustringquando 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.