diff --git a/Directory.Packages.props b/Directory.Packages.props
index bb2b9b1..9c27017 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -39,6 +39,7 @@
+
diff --git a/README.md b/README.md
index 4e91df3..e7c1052 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ auditável de tudo que foi emitido contra ou pelos seus clientes.
| Banco | PostgreSQL 17 · EF Core 10 (Npgsql) |
| Storage | MinIO (S3-compatible) |
| Agendamento | Hangfire |
+| Resiliência | Polly 8 (nova tentativa no object storage) |
| Fiscal | ZeusFiscal (`Hercules.NET.NFe.NFCe`) |
| Frontend | Angular · Signals · NgRx SignalStore · Angular Material |
| Infra | Docker + Docker Compose |
diff --git a/RUNBOOK.md b/RUNBOOK.md
index 0620e39..7618d8e 100644
--- a/RUNBOOK.md
+++ b/RUNBOOK.md
@@ -148,6 +148,19 @@ AGENDAMENTO_ATIVO=false docker compose up -d --build api
A API atende normalmente (grade, relatórios, download), só não roda job nenhum. Para religar, suba de
novo sem a variável: `docker compose up -d api`.
+### Falha ao arquivar no object storage
+
+O arquivamento repete sozinho até três vezes, com espera crescente e jitter, quando a falha é de rede ou
+o MinIO responde 5xx. Erro permanente — arquivo inexistente, bucket errado, credencial inválida — falha
+na primeira tentativa, porque insistir só atrasaria o lote para chegar ao mesmo lugar.
+
+A repetição existe por causa da SEFAZ, não do MinIO: falha ao arquivar aborta o lote, o ponteiro de NSU
+não avança, e a rodada seguinte busca os mesmos documentos na SEFAZ de novo. A consulta é o recurso
+racionado.
+
+Se `storage.indisponivel` aparecer no log da sincronização, as quatro tentativas já se esgotaram — o
+MinIO está fora, não oscilando. Confira `/health/ready` antes de mexer no job.
+
### "A nota não tem itens, CFOP nem impostos na tela"
O documento foi capturado antes de a extração existir: o XML está arquivado, mas nada foi lido dele.
diff --git a/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs b/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs
index 0782b49..6929ebc 100644
--- a/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs
+++ b/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs
@@ -21,6 +21,7 @@
using eContabil.Infrastructure.Persistencia;
using eContabil.Infrastructure.Persistencia.Consultas;
using eContabil.Infrastructure.Persistencia.Repositorios;
+using eContabil.Infrastructure.Resiliencia;
using eContabil.Infrastructure.Sefaz;
using eContabil.Infrastructure.Seguranca;
using eContabil.Infrastructure.Storage;
@@ -31,9 +32,12 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Minio;
+using Polly;
+using Polly.Retry;
namespace Microsoft.Extensions.DependencyInjection;
@@ -112,6 +116,41 @@ public static IServiceCollection AddArmazenamentoESeguranca(
.Build();
});
+ // Nova tentativa curta no object storage.
+ //
+ // Não é resiliência genérica: é economia de cota da SEFAZ. Uma falha ao arquivar aborta o lote
+ // inteiro, o ponteiro de NSU não avança, e a rodada seguinte busca **os mesmos documentos na
+ // SEFAZ de novo** — a consulta é o recurso racionado, a gravação no MinIO não custa nada. Um
+ // soluço de rede de meio segundo não pode custar uma ida à SEFAZ.
+ //
+ // Sem disjuntor de propósito. Ele é por processo e em memória, enquanto o que precisa ser
+ // contido aqui já é contido no lugar certo: o bloqueio por empresa, que vive no banco, sobrevive
+ // a reinício e vale para todas as instâncias. Um disjuntor sobre "o MinIO" ainda pararia a
+ // captura de todas as empresas por causa de uma, sem nada em troca — se o storage caiu de vez, o
+ // job já falha rápido e o ciclo seguinte retoma.
+ services.AddResiliencePipeline(FalhaTransitoriaDeStorage.Pipeline, (construtor, contexto) =>
+ {
+ construtor
+ .ConfigureTelemetry(contexto.ServiceProvider.GetRequiredService())
+ .AddRetry(new RetryStrategyOptions
+ {
+ MaxRetryAttempts = 3,
+ BackoffType = DelayBackoffType.Exponential,
+
+ // Jitter porque a sincronização roda em paralelo por empresa: sem ele, as que
+ // falharem juntas voltariam juntas, e a segunda tentativa recriaria a rajada.
+ UseJitter = true,
+ Delay = TimeSpan.FromMilliseconds(200),
+ ShouldHandle = argumentos => ValueTask.FromResult(
+ argumentos.Outcome.Exception is { } falha
+ && FalhaTransitoriaDeStorage.Ehtransitoria(falha))
+ })
+
+ // Mais interno: vale por tentativa. Sem ele, uma conexão pendurada seguraria o worker
+ // até o tempo limite do próprio job, e as demais empresas ficariam esperando atrás.
+ .AddTimeout(TimeSpan.FromSeconds(30));
+ });
+
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/eContabil.Infrastructure/Resiliencia/FalhaTransitoriaDeStorage.cs b/src/eContabil.Infrastructure/Resiliencia/FalhaTransitoriaDeStorage.cs
new file mode 100644
index 0000000..b945131
--- /dev/null
+++ b/src/eContabil.Infrastructure/Resiliencia/FalhaTransitoriaDeStorage.cs
@@ -0,0 +1,42 @@
+using System.Net.Sockets;
+using Minio.Exceptions;
+using Polly.Timeout;
+
+namespace eContabil.Infrastructure.Resiliencia;
+
+///
+/// Decide se uma falha do object storage merece nova tentativa.
+///
+///
+/// A regra é de permissão, não de exclusão: repete só o que se sabe transitório, e trata todo o
+/// resto como definitivo. O SDK do MinIO faz quase toda exceção herdar de —
+/// inclusive ObjectNotFoundException, AccessDeniedException e InvalidBucketNameException.
+/// Um Handle<MinioException> genérico repetiria arquivo inexistente e credencial errada,
+/// gastando tempo do job para chegar ao mesmo lugar.
+///
+/// A lista de permissão também envelhece melhor: uma exceção nova do SDK entra como definitiva, que é o
+/// comportamento seguro. Numa lista de exclusão, ela entraria como repetível sem ninguém decidir isso.
+///
+public static class FalhaTransitoriaDeStorage
+{
+ /// Nome do pipeline registrado na injeção de dependência.
+ public const string Pipeline = "storage-xml";
+
+ public static bool Ehtransitoria(Exception excecao) => excecao switch
+ {
+ // Cancelamento é decisão de quem chamou, nunca falha do storage.
+ OperationCanceledException => false,
+
+ // O MinIO não respondeu, respondeu 5xx ou cortou a resposta no meio.
+ ConnectionException or InternalServerException or UnexpectedMinioException
+ or UnexpectedShortReadException => true,
+
+ // Rede abaixo do SDK: conexão recusada, reset, DNS momentaneamente fora.
+ HttpRequestException or SocketException or IOException => true,
+
+ // Estouro do limite por tentativa imposto pelo próprio pipeline.
+ TimeoutRejectedException => true,
+
+ _ => false
+ };
+}
diff --git a/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs b/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs
index 675cc16..1bcff63 100644
--- a/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs
+++ b/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs
@@ -3,11 +3,15 @@
using eContabil.Application.Armazenamento;
using eContabil.Domain.ValueObjects;
using eContabil.Infrastructure.Compactacao;
+using eContabil.Infrastructure.Resiliencia;
using eContabil.Shared;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Minio;
using Minio.DataModel.Args;
using Minio.Exceptions;
+using Polly;
+using Polly.Registry;
namespace eContabil.Infrastructure.Storage;
@@ -17,10 +21,29 @@ namespace eContabil.Infrastructure.Storage;
///
/// O nome vem da chave, e não do identificador da empresa, porque é isso que permite **um único objeto**
/// quando a mesma NF-e interessa a duas empresas do escritório. As duas apontam para ele.
+///
+/// Toda chamada ao SDK passa por um pipeline de nova tentativa. As operações usadas aqui são todas
+/// idempotentes — gravar o mesmo objeto com o mesmo conteúdo, ler, conferir existência —, então repetir
+/// não duplica nem corrompe nada.
///
-public sealed class MinioXmlStorage(IMinioClient cliente, IOptions opcoes) : IXmlStorage
+public sealed class MinioXmlStorage : IXmlStorage
{
- private readonly MinioOptions _opcoes = opcoes?.Value ?? throw new ArgumentNullException(nameof(opcoes));
+ private readonly IMinioClient _cliente;
+ private readonly MinioOptions _opcoes;
+ private readonly ResiliencePipeline _tentativas;
+
+ public MinioXmlStorage(
+ IMinioClient cliente,
+ IOptions opcoes,
+ ResiliencePipelineProvider pipelines)
+ {
+ ArgumentNullException.ThrowIfNull(opcoes);
+ ArgumentNullException.ThrowIfNull(pipelines);
+
+ _cliente = cliente;
+ _opcoes = opcoes.Value;
+ _tentativas = pipelines.GetPipeline(FalhaTransitoriaDeStorage.Pipeline);
+ }
public Task> SalvarAsync(
string chaveAcesso, Stream xml, CancellationToken ct) =>
@@ -64,16 +87,7 @@ private async Task> GravarAsync(
_opcoes.BucketXmls, objectName, hash, conteudo.Length, JaExistia: true));
}
- using var origem = new MemoryStream(conteudo);
-
- await cliente.PutObjectAsync(
- new PutObjectArgs()
- .WithBucket(_opcoes.BucketXmls)
- .WithObject(objectName)
- .WithStreamData(origem)
- .WithObjectSize(conteudo.Length)
- .WithContentType("application/xml"),
- ct);
+ await EnviarAsync(_opcoes.BucketXmls, objectName, conteudo, ct);
return Result.Ok(new ObjetoArmazenadoDto(
_opcoes.BucketXmls, objectName, hash, conteudo.Length, JaExistia: false));
@@ -98,18 +112,27 @@ public async Task> ObterAsync(string bucket, string objectName, C
{
try
{
- // O conteúdo é copiado para um buffer porque o SDK entrega o corpo por callback e fecha a
- // conexão ao final da chamada. XML de NF-e tem dezenas de KB — o custo é irrelevante.
- var destino = new MemoryStream();
-
- await cliente.GetObjectAsync(
- new GetObjectArgs()
- .WithBucket(bucket)
- .WithObject(objectName)
- .WithCallbackStream((fluxo, cancelamento) => fluxo.CopyToAsync(destino, cancelamento)),
+ // O buffer nasce dentro do pipeline: uma tentativa que falhou no meio da cópia deixa bytes
+ // parciais nele, e reaproveitá-lo na tentativa seguinte concatenaria os dois pedaços.
+ var lido = await _tentativas.ExecuteAsync(
+ async cancelamento =>
+ {
+ // O conteúdo é copiado para um buffer porque o SDK entrega o corpo por callback e
+ // fecha a conexão ao final da chamada. XML de NF-e tem dezenas de KB.
+ var destino = new MemoryStream();
+
+ await _cliente.GetObjectAsync(
+ new GetObjectArgs()
+ .WithBucket(bucket)
+ .WithObject(objectName)
+ .WithCallbackStream((fluxo, token) => fluxo.CopyToAsync(destino, token)),
+ cancelamento);
+
+ return destino.ToArray();
+ },
ct);
- var conteudo = ConteudoGzip.Descompactar(destino.ToArray());
+ var conteudo = ConteudoGzip.Descompactar(lido);
return Result.Ok(new MemoryStream(conteudo, writable: false));
}
@@ -142,16 +165,7 @@ public async Task> SubstituirAsync(
try
{
- using var origem = new MemoryStream(bytes);
-
- await cliente.PutObjectAsync(
- new PutObjectArgs()
- .WithBucket(bucket)
- .WithObject(objectName)
- .WithStreamData(origem)
- .WithObjectSize(bytes.Length)
- .WithContentType("application/xml"),
- ct);
+ await EnviarAsync(bucket, objectName, bytes, ct);
return Result.Ok(new ObjetoArmazenadoDto(
bucket,
@@ -171,8 +185,10 @@ public async Task ExisteAsync(string bucket, string objectName, Cancellati
{
try
{
- await cliente.StatObjectAsync(
- new StatObjectArgs().WithBucket(bucket).WithObject(objectName), ct);
+ await _tentativas.ExecuteAsync(
+ async cancelamento => await _cliente.StatObjectAsync(
+ new StatObjectArgs().WithBucket(bucket).WithObject(objectName), cancelamento),
+ ct);
return true;
}
@@ -182,6 +198,34 @@ await cliente.StatObjectAsync(
}
}
+ ///
+ /// Envia o conteúdo para o objeto, repetindo enquanto a falha for transitória.
+ ///
+ ///
+ /// O é criado dentro do pipeline, uma vez por tentativa. Um stream
+ /// só é lido do início ao fim: reaproveitá-lo faria a segunda tentativa encontrá-lo no fim e gravar
+ /// um objeto de zero byte — sem erro nenhum, porque do ponto de vista do SDK o envio deu certo.
+ ///
+ /// Repetir é seguro porque a operação é idempotente: o mesmo nome com o mesmo conteúdo produz
+ /// exatamente o mesmo objeto, e o XML autorizado nunca muda.
+ ///
+ private async Task EnviarAsync(string bucket, string objectName, byte[] conteudo, CancellationToken ct) =>
+ await _tentativas.ExecuteAsync(
+ async cancelamento =>
+ {
+ using var origem = new MemoryStream(conteudo, writable: false);
+
+ await _cliente.PutObjectAsync(
+ new PutObjectArgs()
+ .WithBucket(bucket)
+ .WithObject(objectName)
+ .WithStreamData(origem)
+ .WithObjectSize(conteudo.Length)
+ .WithContentType("application/xml"),
+ cancelamento);
+ },
+ ct);
+
///
/// Particiona por ano e mês da competência, que a própria chave carrega.
///
diff --git a/src/eContabil.Infrastructure/eContabil.Infrastructure.csproj b/src/eContabil.Infrastructure/eContabil.Infrastructure.csproj
index 77e5440..0fc2d0a 100644
--- a/src/eContabil.Infrastructure/eContabil.Infrastructure.csproj
+++ b/src/eContabil.Infrastructure/eContabil.Infrastructure.csproj
@@ -24,6 +24,7 @@
+
diff --git a/tests/eContabil.Infrastructure.Tests/Resiliencia/FalhaTransitoriaDeStorageTests.cs b/tests/eContabil.Infrastructure.Tests/Resiliencia/FalhaTransitoriaDeStorageTests.cs
new file mode 100644
index 0000000..03a31eb
--- /dev/null
+++ b/tests/eContabil.Infrastructure.Tests/Resiliencia/FalhaTransitoriaDeStorageTests.cs
@@ -0,0 +1,80 @@
+using System.Net.Sockets;
+using eContabil.Infrastructure.Resiliencia;
+using Minio.Exceptions;
+using Polly.Timeout;
+using Xunit;
+
+namespace eContabil.Infrastructure.Tests.Resiliencia;
+
+///
+/// O que separa uma nova tentativa útil de tempo jogado fora.
+///
+///
+/// Quase toda exceção do SDK do MinIO herda de MinioException, inclusive as permanentes. Estes
+/// testes existem para travar isso: são a diferença entre repetir um soluço de rede e repetir três vezes
+/// uma credencial errada.
+///
+public class FalhaTransitoriaDeStorageTests
+{
+ [Theory]
+ [InlineData(typeof(ConnectionException))]
+ [InlineData(typeof(InternalServerException))]
+ [InlineData(typeof(UnexpectedShortReadException))]
+ public void Ehtransitoria_ComFalhaDeRedeOuDoServidor_MandaTentarDeNovo(Type tipo)
+ {
+ var excecao = (Exception)Activator.CreateInstance(tipo)!;
+
+ Assert.True(FalhaTransitoriaDeStorage.Ehtransitoria(excecao));
+ }
+
+ [Fact]
+ public void Ehtransitoria_ComFalhaAbaixoDoSdk_MandaTentarDeNovo()
+ {
+ // Conexão recusada e reset não chegam como exceção do MinIO: vêm da pilha de rede.
+ Assert.True(FalhaTransitoriaDeStorage.Ehtransitoria(new HttpRequestException("connection refused")));
+ Assert.True(FalhaTransitoriaDeStorage.Ehtransitoria(new SocketException()));
+ Assert.True(FalhaTransitoriaDeStorage.Ehtransitoria(new IOException("reset")));
+ }
+
+ [Fact]
+ public void Ehtransitoria_ComEstouroDoTempoLimiteDaTentativa_MandaTentarDeNovo()
+ {
+ Assert.True(FalhaTransitoriaDeStorage.Ehtransitoria(new TimeoutRejectedException()));
+ }
+
+ [Theory]
+ [InlineData(typeof(ObjectNotFoundException))]
+ [InlineData(typeof(BucketNotFoundException))]
+ [InlineData(typeof(AccessDeniedException))]
+ [InlineData(typeof(ForbiddenException))]
+ [InlineData(typeof(InvalidObjectNameException))]
+ [InlineData(typeof(InvalidBucketNameException))]
+ [InlineData(typeof(EntityTooLargeException))]
+ public void Ehtransitoria_ComErroPermanenteDoMinio_NaoTentaDeNovo(Type tipo)
+ {
+ // Todas herdam de MinioException. Um `Handle` genérico repetiria arquivo
+ // inexistente e credencial errada três vezes para chegar exatamente ao mesmo lugar.
+ var excecao = (Exception)Activator.CreateInstance(tipo)!;
+
+ Assert.IsAssignableFrom(excecao);
+ Assert.False(FalhaTransitoriaDeStorage.Ehtransitoria(excecao));
+ }
+
+ [Fact]
+ public void Ehtransitoria_ComCancelamento_NaoTentaDeNovo()
+ {
+ // Cancelar é decisão de quem chamou — o desligamento do worker, por exemplo. Insistir aqui
+ // seguraria o encerramento da aplicação por conta de um pipeline que se recusa a parar.
+ Assert.False(FalhaTransitoriaDeStorage.Ehtransitoria(new OperationCanceledException()));
+ Assert.False(FalhaTransitoriaDeStorage.Ehtransitoria(new TaskCanceledException()));
+ }
+
+ [Fact]
+ public void Ehtransitoria_ComExcecaoDesconhecida_NaoTentaDeNovo()
+ {
+ // A regra é de permissão: o que não foi reconhecido como transitório é tratado como definitivo.
+ // Uma exceção nova do SDK entra por aqui, e o padrão seguro é não repetir.
+ Assert.False(FalhaTransitoriaDeStorage.Ehtransitoria(new InvalidOperationException()));
+ Assert.False(FalhaTransitoriaDeStorage.Ehtransitoria(new MinioException("erro genérico")));
+ }
+}
diff --git a/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs b/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs
index cce2375..d7131e5 100644
--- a/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs
+++ b/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs
@@ -1,8 +1,11 @@
using System.Globalization;
using eContabil.Infrastructure.Storage;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Minio;
using Minio.DataModel.Args;
+using Polly.Registry;
using Testcontainers.Minio;
using Xunit;
@@ -71,6 +74,26 @@ public async ValueTask InitializeAsync()
await CriarBucketAsync(Opcoes.Value.BucketCertificados);
}
+ ///
+ /// Monta o adaptador com o pipeline de resiliência real, como a aplicação o registra.
+ ///
+ ///
+ /// Pipeline de verdade, e não ResiliencePipeline.Empty: é o registro real que decide o que é
+ /// falha transitória, e testar com um pipeline vazio provaria que o adaptador funciona numa
+ /// configuração que nunca roda em lugar nenhum.
+ ///
+ public MinioXmlStorage CriarStorage() =>
+ new(Cliente, Opcoes, _pipelines ??= MontarPipelines());
+
+ private ResiliencePipelineProvider? _pipelines;
+
+ private static ResiliencePipelineProvider MontarPipelines() =>
+ new ServiceCollection()
+ .AddLogging()
+ .AddArmazenamentoESeguranca(new ConfigurationBuilder().Build())
+ .BuildServiceProvider()
+ .GetRequiredService>();
+
private async Task CriarBucketAsync(string bucket)
{
if (!await Cliente.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucket)))
diff --git a/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageResilienciaTests.cs b/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageResilienciaTests.cs
new file mode 100644
index 0000000..c20c5e1
--- /dev/null
+++ b/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageResilienciaTests.cs
@@ -0,0 +1,194 @@
+using System.Reflection;
+using System.Text;
+using eContabil.Infrastructure.Storage;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Minio;
+using Minio.DataModel;
+using Minio.DataModel.Args;
+using Minio.DataModel.Response;
+using Minio.Exceptions;
+using NSubstitute;
+using Polly.Registry;
+using Xunit;
+
+namespace eContabil.Infrastructure.Tests.Storage;
+
+///
+/// A nova tentativa no object storage economiza cota da SEFAZ.
+///
+///
+/// Uma falha ao arquivar aborta o lote inteiro e o ponteiro de NSU não avança — a rodada seguinte busca
+/// os mesmos documentos na SEFAZ de novo. A consulta é o recurso racionado; a gravação no MinIO não
+/// custa nada. Um soluço de rede não pode custar uma ida à SEFAZ.
+///
+/// O cliente é substituído aqui de propósito: provocar falha transitória num MinIO de verdade não é
+/// reproduzível. O comportamento contra o serviço real está em .
+///
+public class MinioXmlStorageResilienciaTests
+{
+ private const string ChaveValida = "35260711222333000181550010000123451123456787";
+
+ private readonly IMinioClient _cliente = Substitute.For();
+
+ [Fact]
+ public async Task SalvarAsync_ComFalhaTransitoriaQueSeResolve_ArquivaSemPropagarErro()
+ {
+ var tentativas = 0;
+
+ _cliente.PutObjectAsync(Arg.Any(), Arg.Any())
+ .Returns(_ => ++tentativas < 3
+ ? throw new ConnectionException("conexão recusada")
+ : Task.FromResult(null!));
+
+ var resultado = await Montar().SalvarAsync(
+ ChaveValida, Xml(), TestContext.Current.CancellationToken);
+
+ Assert.True(resultado.Sucesso);
+ Assert.Equal(3, tentativas);
+ }
+
+ [Fact]
+ public async Task SalvarAsync_AoTentarDeNovo_EnviaOConteudoInteiroEmCadaTentativa()
+ {
+ // O stream nasce dentro do pipeline, uma vez por tentativa. Reaproveitá-lo faria a segunda
+ // tentativa encontrá-lo no fim e gravar um objeto de zero byte — sem erro nenhum, porque do
+ // ponto de vista do SDK o envio deu certo. O XML sumiria em silêncio.
+ var lidos = new List();
+
+ _cliente.PutObjectAsync(Arg.Any(), Arg.Any())
+ .Returns(chamada =>
+ {
+ lidos.Add(LerConteudo(chamada.Arg()!).Length);
+
+ return lidos.Count < 2
+ ? throw new ConnectionException("conexão caiu no meio do envio")
+ : Task.FromResult(null!);
+ });
+
+ var resultado = await Montar().SalvarAsync(
+ ChaveValida, Xml(), TestContext.Current.CancellationToken);
+
+ Assert.True(resultado.Sucesso);
+ Assert.Equal(2, lidos.Count);
+ Assert.All(lidos, tamanho => Assert.Equal(ConteudoXml.Length, tamanho));
+ }
+
+ [Fact]
+ public async Task SalvarAsync_ComErroPermanente_FalhaNaPrimeiraTentativa()
+ {
+ // Credencial errada não melhora com insistência: repetir só atrasaria o lote para chegar ao
+ // mesmo lugar, segurando o worker enquanto as outras empresas esperam.
+ var tentativas = 0;
+
+ _cliente.PutObjectAsync(Arg.Any(), Arg.Any())
+ .Returns>(_ =>
+ {
+ tentativas++;
+ throw new AccessDeniedException("credencial inválida");
+ });
+
+ var resultado = await Montar().SalvarAsync(
+ ChaveValida, Xml(), TestContext.Current.CancellationToken);
+
+ Assert.True(resultado.Falha);
+ Assert.Equal("storage.indisponivel", resultado.Erro!.Codigo);
+ Assert.Equal(1, tentativas);
+ }
+
+ [Fact]
+ public async Task SalvarAsync_ComFalhaQueNaoCede_DesisteEDevolveResultadoEmVezDeLancar()
+ {
+ // Quem chamou precisa saber que o NSU não pode avançar. Exceção subindo faria o lote inteiro
+ // parecer erro interno da aplicação.
+ var tentativas = 0;
+
+ _cliente.PutObjectAsync(Arg.Any(), Arg.Any())
+ .Returns>(_ =>
+ {
+ tentativas++;
+ throw new ConnectionException("MinIO fora");
+ });
+
+ var resultado = await Montar().SalvarAsync(
+ ChaveValida, Xml(), TestContext.Current.CancellationToken);
+
+ Assert.True(resultado.Falha);
+
+ // Uma tentativa original mais três novas.
+ Assert.Equal(4, tentativas);
+ }
+
+ [Fact]
+ public async Task ObterAsync_ComObjetoInexistente_NaoTentaDeNovo()
+ {
+ var tentativas = 0;
+
+ _cliente.GetObjectAsync(Arg.Any(), Arg.Any())
+ .Returns>(_ =>
+ {
+ tentativas++;
+ throw new ObjectNotFoundException();
+ });
+
+ var resultado = await Montar().ObterAsync(
+ "xmls", "2026/07/inexistente.xml", TestContext.Current.CancellationToken);
+
+ Assert.True(resultado.Falha);
+ Assert.Equal("storage.objeto_inexistente", resultado.Erro!.Codigo);
+ Assert.Equal(1, tentativas);
+ }
+
+ private MinioXmlStorage Montar()
+ {
+ // Objeto ainda não arquivado: sem isso, `SalvarAsync` encerraria em "já existia" sem gravar.
+ _cliente.StatObjectAsync(Arg.Any(), Arg.Any())
+ .Returns>(_ => throw new ObjectNotFoundException());
+
+ var opcoes = Options.Create(new MinioOptions
+ {
+ Endpoint = "localhost:9000",
+ AccessKey = "chave",
+ SecretKey = "segredo",
+ UsarSsl = false,
+ BucketXmls = "xmls",
+ BucketCertificados = "certificados"
+ });
+
+ return new MinioXmlStorage(_cliente, opcoes, Pipelines());
+ }
+
+ ///
+ /// O registro real da aplicação, não um pipeline montado à mão: é ele que decide o que é falha
+ /// transitória, e é ele que precisa estar sob teste.
+ ///
+ private static ResiliencePipelineProvider Pipelines() =>
+ new ServiceCollection()
+ .AddLogging()
+ .AddArmazenamentoESeguranca(new ConfigurationBuilder().Build())
+ .BuildServiceProvider()
+ .GetRequiredService>();
+
+ private const string ConteudoXml =
+ """""";
+
+ private static MemoryStream Xml() => new(Encoding.UTF8.GetBytes(ConteudoXml));
+
+ /// Lê o que a tentativa entregou ao SDK.
+ ///
+ /// Por reflexão porque o SDK declara ObjectStreamData como interno. É o preço de provar, sem
+ /// depender de um MinIO de verdade, que cada tentativa recebe o conteúdo inteiro — o defeito que
+ /// este teste tranca grava objeto de zero byte sem erro nenhum.
+ ///
+ private static string LerConteudo(PutObjectArgs argumentos)
+ {
+ var propriedade = typeof(PutObjectArgs).GetProperty(
+ "ObjectStreamData", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
+
+ var fluxo = (Stream)propriedade!.GetValue(argumentos)!;
+ using var leitor = new StreamReader(fluxo, Encoding.UTF8, leaveOpen: true);
+
+ return leitor.ReadToEnd();
+ }
+}
diff --git a/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs b/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs
index af08ac5..506cd4e 100644
--- a/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs
+++ b/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs
@@ -17,7 +17,7 @@ public async Task SalvarAsync_ComXmlNovo_ArquivaParticionadoPorAnoEMes()
return;
}
- var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes);
+ var storage = minio.CriarStorage();
var chave = ChaveDe(1);
var resultado = await storage.SalvarAsync(chave, Xml(), TestContext.Current.CancellationToken);
@@ -43,7 +43,7 @@ public async Task SalvarEventoAsync_NaoOcupaOLugarDaNota()
return;
}
- var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes);
+ var storage = minio.CriarStorage();
var chave = ChaveDe(90);
var evento = await storage.SalvarEventoAsync(
@@ -67,7 +67,7 @@ public async Task SalvarEventoAsync_ComNsusDiferentes_NaoSobrescreveOEventoAnter
return;
}
- var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes);
+ var storage = minio.CriarStorage();
var chave = ChaveDe(91);
var primeiro = await storage.SalvarEventoAsync(
@@ -89,7 +89,7 @@ public async Task SalvarAsync_ComOMesmoXmlDuasVezes_NaoRegrava()
return;
}
- var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes);
+ var storage = minio.CriarStorage();
await storage.SalvarAsync(ChaveDe(2), Xml(), TestContext.Current.CancellationToken);
// O retry de um job de sincronização passa por aqui o tempo todo, e a mesma NF-e ainda interessa
@@ -109,7 +109,7 @@ public async Task ObterAsync_ComObjetoArquivado_DevolveOsMesmosBytes()
return;
}
- var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes);
+ var storage = minio.CriarStorage();
var gravado = await storage.SalvarAsync(ChaveDe(3), Xml(), TestContext.Current.CancellationToken);
var lido = await storage.ObterAsync(
@@ -129,7 +129,7 @@ public async Task ObterAsync_ComObjetoInexistente_RetornaNaoEncontradoSemLancar(
return;
}
- var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes);
+ var storage = minio.CriarStorage();
var resultado = await storage.ObterAsync(
"xmls", "2026/07/inexistente.xml", TestContext.Current.CancellationToken);
@@ -147,7 +147,7 @@ public async Task SalvarAsync_ComChaveInvalida_NemChegaAoStorage()
return;
}
- var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes);
+ var storage = minio.CriarStorage();
var resultado = await storage.SalvarAsync("123", Xml(), TestContext.Current.CancellationToken);
@@ -163,7 +163,7 @@ public async Task SalvarAsync_ComConteudoVazio_Falha()
return;
}
- var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes);
+ var storage = minio.CriarStorage();
var resultado = await storage.SalvarAsync(
ChaveDe(4), new MemoryStream(), TestContext.Current.CancellationToken);
@@ -186,7 +186,7 @@ public async Task ObterAsync_ComObjetoArquivadoEmGzip_DevolveOXmlLegivel()
return;
}
- var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes);
+ var storage = minio.CriarStorage();
var chave = ChaveDe(200);
var arquivado = await storage.SalvarAsync(
@@ -215,7 +215,7 @@ public async Task SubstituirAsync_TrocaOConteudoEDevolveONovoHash()
return;
}
- var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes);
+ var storage = minio.CriarStorage();
var chave = ChaveDe(201);
var original = await storage.SalvarAsync(
@@ -240,7 +240,7 @@ public async Task SubstituirAsync_ComConteudoVazio_RecusaEmVezDeApagarOArquivo()
return;
}
- var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes);
+ var storage = minio.CriarStorage();
var resultado = await storage.SubstituirAsync(
"xmls", "2026/07/qualquer.xml", new MemoryStream(), TestContext.Current.CancellationToken);