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.

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 ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Hay 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ónLo que vuelve
GET /api/formats/malformed-jsoninicia sesión para verlo200, application/json, JSON inválido y truncado
GET /api/formats/emptyinicia sesión para verlo200 con un cuerpo vacío
GET /api/formats/no-contentinicia sesión para verlo204 No Content
GET /api/formats/wrong-content-typeinicia sesión para verloJSON válido etiquetado como text/html
GET /api/formats/bominicia sesión para verloJSON válido con una marca de orden de bytes UTF-8 delante
GET /api/edge-cases/duplicate-keysinicia sesión para verloJSON válido con la misma clave dos veces
GET /api/edge-cases/numbersinicia sesión para verloLí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 verloUna cadena JSON donde se espera un objeto
GET /api/edge-cases/mixed-arrayinicia sesión para verloUn 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:

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 ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita
Salida
/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 ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita
RespuestaResultadoPor qué importa
JSON truncado o inválidoJsonException, con Path, LineNumber y BytePositionInLineCaptúrala e informa de qué llamada se rompió, o aparecerá como un fallo sin explicación
200 vacío, o 204JsonException: no hay tokens JSONComprueba si el cuerpo está vacío antes de analizarlo cuando la API pueda enviar uno
JSON etiquetado como text/htmlSe analiza sin quejasReadFromJsonAsync no mira el Content-Type. Una página de error HTML real falla más tarde, como JSON inválido
Marca de orden de bytesSe analizaSe gestiona por ti
La misma clave dos vecesSe analiza: gana el último valor, sin avisarid 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 maloPath 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.

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 ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita
Salida
{"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

Un 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.

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 ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita
Salida
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 ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

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.

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 ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

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, o string cuando 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.