Start Debugging

Correção: HttpProtocolException: The HTTP/2 server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR'

Um peer enviou RST_STREAM com PROTOCOL_ERROR para uma requisição. O HttpClient nunca faz retry dela. Descubra qual hop resetou o stream, corrija a requisição rejeitada, faça retry apenas de chamadas idempotentes ou rebaixe esse hop para HTTP/1.1.

HttpProtocolException: The HTTP/2 server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR' (0x1) significa que a outra ponta da conexão HTTP/2 enviou um frame RST_STREAM para a sua requisição com o código de erro 1. O HttpClient não quebrou nada, ele apenas relata o que o peer disse, e nunca faz retry de um PROTOCOL_ERROR por conta própria. A correção é descobrir qual hop enviou o reset (seu app Kestrel ou um proxy, load balancer ou sidecar na frente dele), corrigir o que naquela requisição específica foi rejeitado, fazer retry de requisições idempotentes com um resilience handler e, como último recurso, usar HTTP/1.1 com o hop que se comporta mal.

Tudo abaixo foi reproduzido no .NET 10.0.10 (SDK 10.0.302, ASP.NET Core 10.0.10) com Kestrel e HttpClient em um único app baseado em arquivo, mais Microsoft.Extensions.Http.Resilience 10.0.0 para a seção de retry. Os caminhos de código relevantes são os mesmos no .NET 8, 9 e 11.

O erro em contexto

Quando o reset chega antes dos cabeçalhos da resposta, SendAsync (ou GetAsync, PostAsync) lança uma HttpRequestException que encapsula a HttpProtocolException:

System.Net.Http.HttpRequestException: The HTTP/2 server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR' (0x1). (HttpProtocolError)
 ---> System.Net.Http.HttpProtocolException: The HTTP/2 server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR' (0x1). (HttpProtocolError)
   at System.Net.Http.Http2Connection.ThrowRequestAborted(Exception innerException)
   at System.Net.Http.Http2Connection.Http2Stream.CheckResponseBodyState()
   at System.Net.Http.Http2Connection.Http2Stream.TryEnsureHeaders()
   at System.Net.Http.Http2Connection.Http2Stream.ReadResponseHeadersAsync(CancellationToken cancellationToken)
   at System.Net.Http.Http2Connection.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
   --- End of inner exception stack trace ---
   at System.Net.Http.Http2Connection.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
   at System.Net.Http.HttpConnectionPool.SendWithVersionDetectionAndRetryAsync(HttpRequestMessage request, Boolean async, Boolean doRequestAuth, CancellationToken cancellationToken)
   at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)

Se o servidor já enviou um código de status e o reset acontece enquanto o corpo está sendo lido, a mensagem externa muda, mas a exceção interna é idêntica:

System.Net.Http.HttpRequestException: Error while copying content to a stream.
 ---> System.Net.Http.HttpProtocolException: The HTTP/2 server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR' (0x1). (HttpProtocolError)

Nos dois casos, HttpRequestException.HttpRequestError é HttpRequestError.HttpProtocolError e HttpProtocolException.ErrorCode é 1. Verifique essas duas propriedades no código em vez do texto da mensagem.

A redação “the HTTP/2 server reset the stream” também é importante, porque o SocketsHttpHandler tem três mensagens de falha HTTP/2 diferentes e cada uma aponta para um problema distinto:

MensagemFrame que o cliente recebeuEscopo
The HTTP/2 server reset the stream. HTTP/2 error code '...'RST_STREAMApenas esta requisição
The HTTP/2 server closed the connection. HTTP/2 error code '...'GOAWAY com um erroTodos os streams dessa conexão
The HTTP/2 server sent invalid data on the connection. HTTP/2 error code '...'nenhum, o próprio cliente detectou a violaçãoA conexão inteira

Este post cobre a primeira linha. Se você vir a segunda ou a terceira, a própria conexão não está saudável e o diagnóstico é outro.

Por que isso acontece

PROTOCOL_ERROR é o código mais genérico da RFC 9113 seção 7. Um peer o envia como erro de stream quando uma requisição (seus cabeçalhos, seu corpo ou seu enquadramento) viola uma regra, e o peer quer rejeitar essa requisição sem derrubar a conexão compartilhada. Esse design explica por que o erro parece aleatório “sob carga”: uma conexão HTTP/2 transporta centenas de requisições simultâneas, o reset atinge apenas a requisição que disparou uma verificação e as demais têm sucesso. Mais tráfego significa mais requisições com a propriedade rara que dispara a verificação, então a taxa de falhas sobe com a carga mesmo que a carga não seja a causa.

As causas, ordenadas pela frequência com que aparecem para um cliente .NET:

  1. Um proxy no meio rejeitou algo naquela requisição. Reverse proxies, service meshes, ingress controllers e load balancers de nuvem terminam o HTTP/2 e validam requisições com regras próprias: caracteres de cabeçalho que consideram inválidos, valores de cabeçalho com várias linhas, cabeçalhos específicos de conexão, limites de tamanho para cabeçalhos ou corpos. Exemplos reais: o Envoy resetando streams que carregam caracteres malformados em cabeçalhos, o nginx falhando com um valor de cabeçalho de várias linhas sem registrar nada, e o próprio Kestrel rejeitando pseudo-cabeçalhos depois de cabeçalhos regulares enviados por um hop Envoy.
  2. O corpo da requisição não correspondia ao seu Content-Length. O Kestrel reseta o stream com PROTOCOL_ERROR quando recebe mais ou menos bytes DATA do que o tamanho declarado (Http2StreamErrorMoreDataThanLength e Http2StreamErrorLessDataThanLength em Http2Stream.cs). Um HttpClient do .NET detecta a própria divergência antes do servidor (você recebe Sent 5 request content bytes, but Content-Length promised 50.), então quando o Kestrel registra isso, normalmente é um cliente que não é .NET, ou um proxy que reescreveu o corpo sem corrigir o cabeçalho.
  3. O Kestrel rejeitou os pseudo-cabeçalhos da requisição. :method inválido, :path inválido, :scheme divergente, um Host/:authority ruim ou um CONNECT estendido malformado terminam em ResetAndAbort(..., Http2ErrorCode.PROTOCOL_ERROR) no Kestrel. O HttpClient monta esses cabeçalhos sozinho, então você só chega a isso por meio de um proxy ou de um cliente feito à mão.
  4. O código da aplicação resetou o stream. Qualquer middleware ou handler que chame IHttpResetFeature.Reset(1) produz exatamente este erro no cliente. É raro, mas alguns gateways e interceptors de gRPC fazem isso de propósito para sinalizar “requisição malformada”.

Uma coisa que normalmente não é a causa: o cliente .NET enviando cabeçalhos ruins diretamente ao Kestrel. No meu repro, TE: gzip, Connection: keep-alive e um cabeçalho personalizado de 300 KB enviados por h2c ao Kestrel 10.0.10 voltaram todos como um 400 Bad Request normal, não como um reset. Se você vir PROTOCOL_ERROR contra um backend Kestrel, suspeite primeiro do que está entre o cliente e o Kestrel.

Repro mínimo

A forma mais simples de reproduzir a exceção exata é um endpoint Kestrel que reseta o próprio stream por meio de IHttpResetFeature, que é, byte a byte, o que um RST_STREAM de proxy parece do lado do cliente.

// .NET 10.0.10, C# 14. Run with: dotnet run repro.cs
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using System.Net;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Server.Kestrel.Core;

var hits = 0;
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
// h2c (HTTP/2 without TLS) needs an HTTP/2-only endpoint
builder.WebHost.ConfigureKestrel(k => k.ListenLocalhost(5199, o => o.Protocols = HttpProtocols.Http2));
var app = builder.Build();
app.MapGet("/reset/{code:int}", (HttpContext ctx, int code) =>
{
    Interlocked.Increment(ref hits);
    ctx.Features.Get<IHttpResetFeature>()!.Reset(code); // 1 = PROTOCOL_ERROR, 7 = REFUSED_STREAM
    return Results.Empty;
});
await app.StartAsync();

var client = new HttpClient
{
    DefaultRequestVersion = HttpVersion.Version20,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
};

foreach (var code in new[] { 1, 7 })
{
    hits = 0;
    try { await client.GetAsync($"http://localhost:5199/reset/{code}"); }
    catch (HttpRequestException e) when (e.InnerException is HttpProtocolException p)
    {
        Console.WriteLine($"code {code}: {e.HttpRequestError}, ErrorCode={p.ErrorCode}, server hits={hits}");
        Console.WriteLine($"  outer: {e.Message}");
    }
}
await app.StopAsync();

Saída:

code 1: HttpProtocolError, ErrorCode=1, server hits=1
  outer: The HTTP/2 server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR' (0x1). (HttpProtocolError)
code 7: HttpProtocolError, ErrorCode=7, server hits=4
  outer: The request was aborted.

A coluna server hits é a parte útil. Um reset PROTOCOL_ERROR chega ao servidor uma vez e é lançado para você. Um reset REFUSED_STREAM sofre retry pelo próprio SocketsHttpHandler, porque a RFC 9113 garante que o peer não processou um stream recusado. O pool desiste depois de MaxConnectionFailureRetries = 3 retries, então o servidor viu 4 tentativas. A fonte é Http2Stream.OnReset, onde canRetry é verdadeiro apenas para REFUSED_STREAM e para streams que um GOAWAY marcou como não processados.

A correção, em detalhe

1. Descubra qual hop enviou o reset

O cliente não consegue dizer quem enviou o RST_STREAM. Ele só sabe que o frame veio do peer ao qual está conectado, que é o primeiro hop HTTP/2: muitas vezes um proxy, não o seu serviço. Resolva isso antes de mudar código:

// appsettings.Development.json, ASP.NET Core 10
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore.Server.Kestrel.Http2": "Debug",
      "Microsoft.AspNetCore.Server.Kestrel.BadRequests": "Debug"
    }
  }
}

2. Corrija a requisição que o hop rejeitou

Depois de saber qual hop e qual regra, a correção geralmente está na requisição, não no transporte. Registre o método, o caminho, os nomes dos cabeçalhos, os tamanhos dos valores dos cabeçalhos e o tamanho do corpo das requisições que falham (nunca os valores de cabeçalhos de autenticação) e compare com as que tiveram sucesso. Os suspeitos de sempre:

3. Faça retry, mas apenas de requisições seguras para repetir

Quando o reset vem de uma infraestrutura que você não controla, adicione um retry. Um reset PROTOCOL_ERROR não informa se o servidor processou a requisição (um proxy pode resetar depois de encaminhá-la), então limite os retries a métodos idempotentes. Com Microsoft.Extensions.Http.Resilience:

// .NET 10, Microsoft.Extensions.Http.Resilience 10.0.0
using System.Net;
using Microsoft.Extensions.Http.Resilience;
using Polly;

builder.Services.AddHttpClient("api", c =>
    {
        c.BaseAddress = new Uri("https://api.internal");
        c.DefaultRequestVersion = HttpVersion.Version20;
    })
    .AddResilienceHandler("h2-reset", pipeline =>
    {
        var retry = new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 2,
            Delay = TimeSpan.FromMilliseconds(50),
            ShouldHandle = args => ValueTask.FromResult(
                args.Outcome.Exception is HttpRequestException { HttpRequestError: HttpRequestError.HttpProtocolError }),
        };
        retry.DisableForUnsafeHttpMethods(); // never replay POST, PUT, PATCH, DELETE, CONNECT
        pipeline.AddRetry(retry);
    });

Contra um endpoint que reseta a primeira tentativa e tem sucesso na segunda, um GET por esse cliente retornou ok on attempt 2 via HTTP/2, enquanto um POST ainda lançou o PROTOCOL_ERROR original após um único hit no servidor. Se você já usa AddStandardResilienceHandler, o ShouldHandle padrão dele trata HttpRequestException como transitória e também cobre este caso, mas ele faz retry de todos os métodos, a menos que você chame DisableForUnsafeHttpMethods() em options.Retry. Observe que DisableForUnsafeHttpMethods retorna void, então chame-o no objeto de opções antes de passá-lo a AddRetry, não em uma cadeia fluente.

4. Rebaixe o hop problemático para HTTP/1.1

Se o reset vem de um intermediário com uma implementação HTTP/2 com bugs ou excessivamente estrita, e você não pode corrigi-lo ou atualizá-lo, tire o HTTP/2 desse hop. No cliente:

// .NET 10: ask for HTTP/1.1 for this client only
var client = new HttpClient
{
    DefaultRequestVersion = HttpVersion.Version11,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
};

No HTTP/1.1 não existe stream para resetar: o mesmo endpoint que resetou o stream sobre HTTP/2 respondeu ok on attempt 2 via HTTP/1.1 no repro. Você perde a multiplexação, então espere mais conexões sob carga, e o gRPC não consegue fazer isso, pois exige HTTP/2. Trate isso como um workaround com uma issue aberta contra o proxy, não como a correção.

Pegadinhas e variantes

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar