Manejar JSON mal formado en .NET con System.Text.Json
Qué hace ReadFromJsonAsync con JSON roto o raro, qué casos no lanzan excepción y un lector que convierte cada uno en un error claro.
Ejemplos ejecutados por última vez contra la API el
Una API puede responder 200 OK con un cuerpo que no es JSON válido: una respuesta cortada por un proxy, un cuerpo vacío, una página de error HTML, un valor del tipo equivocado. Cómo reaccione tu código .NET decide si los usuarios ven un error claro o un fallo con una JsonException enterrada en un log. Para probarlo necesitas respuestas rotas a propósito. https://api.testingapis.com tiene una colección; son para miembros con sesión iniciada, y gratuitas. Esta guía muestra exactamente qué hacen System.Text.Json y ReadFromJsonAsync con cada una, los casos que no lanzan pero deberían, un lector seguro y tests de xUnit. Todo se ejecutó en .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": truHay dos cosas mal en esos 61 bytes: una coma final después de "b" y un cuerpo cortado en mitad de true. El estado sigue siendo 200 y el tipo de contenido sigue diciendo JSON.
JSON roto y raro cuando quieras
| Petición | Lo que vuelve |
|---|---|
GET /api/formats/malformed-jsoninicia sesión para verlo | 200, application/json, JSON inválido y truncado |
GET /api/formats/emptyinicia sesión para verlo | 200 con un cuerpo vacío |
GET /api/formats/no-contentinicia sesión para verlo | 204 No Content |
GET /api/formats/wrong-content-typeinicia sesión para verlo | JSON válido etiquetado como text/html |
GET /api/formats/bominicia sesión para verlo | JSON válido con una marca de orden de bytes UTF-8 delante |
GET /api/edge-cases/duplicate-keysinicia sesión para verlo | JSON válido con la misma clave dos veces |
GET /api/edge-cases/numbersinicia sesión para verlo | Límites de int64, números fuera del rango seguro de JavaScript, un entero de 30 dígitos, números como cadenas |
GET /api/edge-cases/top-level/stringinicia sesión para verlo | Una cadena JSON donde se espera un objeto |
GET /api/edge-cases/mixed-arrayinicia sesión para verlo | Un array con números, cadenas, booleanos, null, un objeto y un array |
Qué hace ReadFromJsonAsync con cada una
Cada respuesta se lee en un record sencillo. El JSON inválido lanza una JsonException cuyo Path señala el problema; tres de los casos no lanzan 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| Respuesta | Resultado | Por qué importa |
|---|---|---|
| JSON truncado o inválido | JsonException, con Path, LineNumber y BytePositionInLine | Captúrala e informa de qué llamada se rompió, o aparecerá como un fallo sin explicación |
| 200 vacío, o 204 | JsonException: no hay tokens JSON | Comprueba si el cuerpo está vacío antes de analizarlo cuando la API pueda enviar uno |
| JSON etiquetado como text/html | Se analiza sin quejas | ReadFromJsonAsync no mira el Content-Type. Una página de error HTML real falla más tarde, como JSON inválido |
| Marca de orden de bytes | Se analiza | Se gestiona por ti |
| La misma clave dos veces | Se analiza: gana el último valor, sin avisar | id pasó a 2 y name a "second value". Hazlo un error con AllowDuplicateProperties = false (.NET 10) |
| Forma equivocada (una cadena, un array mixto) | JsonException en el primer valor malo | Path dice dónde: $ para la raíz, $[1] para el segundo elemento |
Números
C# maneja los enteros grandes mejor que JavaScript: un long guarda 9007199254740993 exacto, mientras JavaScript lo redondea. Pero un entero de 30 dígitos no cabe en un long, y los números enviados como cadenas dependen de tus opciones. ReadFromJsonAsync usa los valores por defecto web, que los aceptan; unas opciones sin NumberHandling.AllowReadingFromString no.
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 $.bigIntegerUn lector seguro: un error claro para cada cuerpo malo
Lee primero el cuerpo como cadena, para que el error pueda citarlo, y luego analízalo. Cada fallo se convierte en una InvalidDataException que nombra la URL, el tipo de contenido, dónde se paró el análisis y el principio del cuerpo. Eso es lo que necesitas en un log a las 3 de la madrugada.
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
}El duplicado se notifica en $.id, línea 5: Item solo tiene un Id, así que esa es la propiedad repetida que le importa. Las claves que el tipo no mapea se ignoran en cualquier caso.
Tests de xUnit
Una theory cubre todas las respuestas rotas; un fact comprueba que el JSON bueno se sigue leyendo. Pasan con xUnit 2.9 en .NET 10, usando la clase JsonResponses de arriba.
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);
}
}Qué comprobar en tu cliente
- El JSON inválido con estado 200 es un error, no un fallo: se captura, se registra con la URL y se muestra al usuario.
- Un cuerpo vacío o un 204 se gestiona antes de analizar, donde la API pueda enviarlos.
- Las claves duplicadas se rechazan donde un "gana el último" silencioso escondería un bug.
- Los IDs grandes caben en su tipo:
long, ostringcuando pueden superarlo. - Los errores citan lo suficiente del cuerpo para diagnosticarlo, sin registrar secretos.
Las mismas respuestas sirven en otros lenguajes: la referencia de casos límite de JSON y la referencia de formatos las listan todas, y errores cuando quieras cubre los códigos de estado de fallo.