Na parte 1 a gente definiu execução durável como a capacidade de registrar o progresso de um processo e retomá-lo depois de uma falha ou de uma pausa. Lá ficaram os sete conceitos: registro de progresso, replay, retry, idempotência, determinismo, hibernação e paralelismo durável.

Este post é a parte prática. Vamos implementar um processo de negócio do começo ao fim e olhar, em cada passo, o que pode dar errado e como o sistema se recupera. O exemplo usa .NET, C#, Polly, Hangfire e Postgres, mas cada peça tem equivalente em outras linguagens, e elas aparecem ao longo do texto.

O processo: do pedido à nota fiscal

O usuário cria um pedido na tela. O backend autoriza o pagamento no gateway e espera a confirmação, que pode levar até 3 dias. Se a confirmação chega, o sistema captura o pagamento e emite a nota. Se o prazo vence antes, a autorização expira.

Processo de negócio
Do pedido à nota fiscal
Cada caixa é um passo que pode falhar, ser repetido ou ficar esperando por dias.
Pedido criadotela + backend
Autorizar pagamentogateway
Aguardar confirmaçãoaté 3 dias
Capturargateway
Emitir notaNF-e
Prazo venceexpira a autorização
Se o servidor cair entre autorizar e capturar, o processo precisa saber onde parou, sem cobrar o cliente duas vezes e sem esquecer a nota.

Cada passo desse fluxo fala com algo de fora: banco, gateway de pagamento, serviço de nota. Se o servidor reinicia entre a autorização e a captura, o processo precisa saber onde parou. Se a chamada ao gateway dá timeout, alguém tem que decidir se tenta de novo. E a espera de 3 dias não pode depender de um processo ficar vivo esse tempo todo.

As quatro peças da implementação

Para cobrir esses casos, a implementação tem quatro peças:

  1. Retry, para se recuperar de erros que passam sozinhos, como oscilação de rede ou banco indisponível por alguns segundos.
  2. Progresso salvo no banco, com o resultado de cada passo concluído gravado numa tabela.
  3. Worker que retoma de onde parou, lendo o que já foi feito antes de executar.
  4. Agendador, que guarda tarefas para daqui a horas, dias ou semanas e as dispara na data certa.
A implementação
As quatro peças da execução durável
No exemplo em .NET: Polly para retry, Postgres para o progresso e Hangfire como worker e agendador.
1 · Retry
Tentar de novo
Repete só erros transitórios, com backoff entre as tentativas.
Polly
2 · Progresso
Salvar cada passo
O resultado de cada passo concluído vai para uma tabela no banco.
Postgres
3 · Worker
Retomar de onde parou
Lê o que já foi feito e executa só o que falta.
Hangfire Server
4 · Agendador
Esperar dias
Guarda o job e o dispara na data marcada, horas ou semanas depois.
Hangfire Schedule

No exemplo, o Polly faz o retry, o Postgres guarda o progresso e o Hangfire faz o papel de worker e de agendador.

Retry: tentar de novo só quando o erro passa sozinho

Retry é repetir uma operação que falhou. A condição para isso funcionar é o erro ser transitório, ou seja, um erro que some sem ninguém mexer em nada. Um timeout na chamada ao gateway pode ser só uma sobrecarga daquele servidor naquele momento, e a tentativa seguinte passa. Um HTTP 400 por payload inválido vai falhar na primeira, na segunda e na décima tentativa, porque o problema está no dado.

Dar retry em erro que não é transitório só gasta tempo e carga do serviço do outro lado. Por isso a política de resiliência do exemplo começa com um método que classifica a exceção:

Retry
Só vale tentar de novo se o erro passa sozinho
A primeira pergunta da política de resiliência é se a exceção é transitória.
Exceção no passoEhTransitorio(ex)?
Sim · retry com backoff
Timeout na chamada ao gateway, HTTP 503 ou 429, conexão com o banco derrubada, failover do Postgres.
tenta em 200 ms400 ms800 ms
Não · falha na hora
HTTP 400 com payload inválido, cartão recusado, CNPJ inexistente na SEFAZ. A terceira tentativa vai falhar igual à primeira.
registra o errosegue a regra de negócio
public static class PoliticaDeResiliencia
{
    // O que conta como transitório é decisão do seu time.
    public static bool EhTransitorio(Exception ex) => ex switch
    {
        TimeoutRejectedException => true,
        HttpRequestException { StatusCode: null } => true, // falha de conexão
        HttpRequestException { StatusCode: HttpStatusCode.ServiceUnavailable
                                        or HttpStatusCode.GatewayTimeout
                                        or HttpStatusCode.TooManyRequests } => true,
        NpgsqlException { IsTransient: true } => true,
        _ => false
    };

    public static ResiliencePipeline Criar() =>
        new ResiliencePipelineBuilder()
            .AddRetry(new RetryStrategyOptions
            {
                ShouldHandle = new PredicateBuilder().Handle(EhTransitorio),
                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromMilliseconds(200),
                BackoffType = DelayBackoffType.Exponential // 200 ms, 400 ms, 800 ms
            })
            .Build();
}

Existe discussão sobre o que entra ou não como transitório. HTTP 429, por exemplo, depende de o serviço devolver um Retry-After que você respeite. O ponto é deixar essa classificação num lugar só, onde o time decide e ajusta.

O MaxRetryAttempts define quantas tentativas extras acontecem, três no exemplo. Pode ser fixo ou vir como parâmetro de quem executa o passo. O Delay é o tempo base, e o que o Polly faz com ele depende do BackoffType.

Quanto esperar entre uma tentativa e outra

Backoff é o intervalo entre as tentativas. O Polly tem três tipos, e a diferença fica clara com o mesmo tempo base de 200 ms:

  • Constant espera sempre o mesmo tempo: 200 ms, 200 ms, 200 ms.
  • Linear soma o tempo base a cada tentativa: 200 ms, 400 ms, 600 ms.
  • Exponential dobra o tempo da espera anterior: 200 ms, 400 ms, 800 ms.
Backoff
Quanto esperar entre uma tentativa e outra
Mesmo tempo base de 200 ms e três tentativas extras, com os três tipos do Polly (DelayBackoffType).
Constant · espera sempre o mesmo
tentativa 2200 ms
tentativa 3200 ms
tentativa 4200 ms
Linear · soma o tempo base a cada vez
tentativa 2200 ms
tentativa 3400 ms
tentativa 4600 ms
Exponential · dobra a cada vez (usado no exemplo)
tentativa 2200 ms
tentativa 3400 ms
tentativa 4800 ms

O exemplo usa o exponencial. Ele dá ao serviço do outro lado cada vez mais tempo para se recuperar, em vez de martelar no mesmo ritmo. Com muitas instâncias tentando ao mesmo tempo, vale ligar o UseJitter, que espalha as tentativas e evita que todas batam no serviço no mesmo milissegundo. O mesmo pipeline também pode receber um AddCircuitBreaker, que para de chamar o serviço por um período quando a taxa de falha passa de um limite.

O retry aplicado: o executor de passo

A política sozinha não faz nada. Ela é aplicada quando um passo do processo executa. No exemplo, isso fica num executor de passo genérico, que todo job usa:

var pipeline = PoliticaDeResiliencia.Criar();
var resultado = await pipeline.ExecuteAsync(
    async token => await acao(chave, token),
    ct);

O ExecuteAsync roda a ação dentro da política: se a ação lança uma exceção transitória, o Polly espera o backoff e tenta de novo, até três vezes. Como a política está centralizada no executor, todo passo do processo ganha o mesmo comportamento sem repetir código. Mais adiante o executor ganha a segunda função, que é o replay.

Quem usa HttpClient no .NET tem ainda o AddStandardResilienceHandler, do pacote Microsoft.Extensions.Http.Resilience, que monta um pipeline padrão com Polly por baixo. Nas outras linguagens:

  • Node: cockatiel, p-retry
  • Go: failsafe-go
  • Java e Kotlin: Resilience4j, Spring Retry
  • Python: Tenacity

Dá para fazer retry com fila também. Esse assunto fica para um post específico sobre filas.

Registro de cada passo: o código enfileira, o Hangfire executa

O processo começa no repositório de pedidos. O usuário criou o pedido na tela, a requisição passou pelo backend e chegou no método que grava o pedido. A parte web não importa aqui. O que importa é o que acontece depois do insert.

public async Task CriarAsync(Pedido pedido, CancellationToken ct)
{
    await using var tx = await _conn.BeginTransactionAsync(ct);

    var inserido = await InserirPedidoAsync(pedido, tx, ct);   // ON CONFLICT DO NOTHING
    if (!inserido) return;                                      // pedido já existia

    await RegistrarJobAsync(pedido.Id, "autorizar", tx, ct);   // INSERT pedido_job
    await tx.CommitAsync(ct);

    BackgroundJob.Enqueue(f => f.Autorizar(pedido.Id, CancellationToken.None));
}

O insert do pedido é código padrão, com transação e verificação de pedido duplicado. A execução durável começa nas duas linhas seguintes. Na mesma transação do pedido, o código grava um registro na tabela pedido_job dizendo que o próximo passo é autorizar. Depois do commit, enfileira no Hangfire o método Autorizar do fluxo de pagamento.

O registro em pedido_job é o que permite descobrir e reenfileirar um job que não chegou ao Hangfire, por exemplo se o processo cair entre o commit e o Enqueue.

Repare que o código não executa a autorização. Ele registra que ela precisa acontecer e entrega para o Hangfire, que decide quando executar e grava que executou.

Registro de cada passo
Cada job grava o resultado e enfileira o próximo
O código não executa o passo na hora. Ele registra e entrega para o Hangfire executar.
Uma transação
INSERT pedido
INSERT pedido_jobpasso: autorizar
EnqueueFluxoPagamento.Autorizar
Hangfirefila no storage
Worker executaAutorizar(pedidoId)
Status autorizadoSchedule Expirar daqui a 3 dias + próximo passo
Outro statusenfileira o job que trata o caso
O job sabe qual é o próximo passo pela regra de negócio. O painel do Hangfire mostra o histórico: Autorizar pedido, Decidir pagamento, Emitir documento, e o Expirar confirmação agendado.

O job que conhece o próximo passo

Quando o Hangfire executa o job, o Autorizar busca o pedido no banco e chama o executor de passo, o mesmo que já tem o retry embutido:

[DisplayName("Autorizar pedido {0}")]
public async Task Autorizar(Guid pedidoId, CancellationToken ct)
{
    var pedido = await _pedidos.ObterAsync(pedidoId, ct);

    var autorizacao = await _executor.ExecutarAsync(pedidoId, "autorizar",
        (chave, token) => _gateway.AutorizarAsync(pedido, chave, token), ct);

    await _pedidos.AtualizarStatusAsync(pedidoId, autorizacao.Status, ct);

    if (autorizacao.Status == StatusPedido.Autorizado)
        BackgroundJob.Schedule(
            f => f.Expirar(pedidoId, CancellationToken.None), TimeSpan.FromDays(3));
    else
        BackgroundJob.Enqueue(f => f.DecidirPagamento(pedidoId, CancellationToken.None));
}

Depois de autorizar, o job atualiza o status do pedido e, conforme a regra de negócio, enfileira o próximo. A execução fica encadeada: cada job sabe qual é o passo seguinte. O DisplayName é o nome que aparece no painel do Hangfire, onde dá para ver os jobs processados com sucesso (Autorizar pedido, Decidir pagamento, Emitir documento) e os agendados, como o Expirar confirmação marcado para daqui a 3 dias.

O Hangfire tem o próprio retry em nível de job: o AutomaticRetry reexecuta um job que falhou, 10 vezes por padrão. Isso é diferente do retry do Polly, e a diferença fica clara na seção sobre replay.

O que a infraestrutura precisa garantir

Esse desenho tem limites que o código não resolve. Se um job foi enfileirado para rodar hoje às 11h e o host do Hangfire está fora do ar, ele não roda às 11h. Roda quando o Hangfire voltar. O mesmo vale se o banco que guarda os jobs cair ou se a rede entre o Hangfire e esse banco falhar.

Limites
O código durável depende da infraestrutura que roda embaixo
Se qualquer peça abaixo cair, o job agendado para as 11h só executa quando ela voltar.
Processo
Host do Hangfire
O servidor que roda os workers precisa estar de pé e monitorado.
Storage
Banco dos jobs
É onde ficam a fila, os agendamentos e o histórico. Se ele cai, nada anda.
Rede
Host ↔ banco
Sem conexão entre os dois, o worker não lê nem grava jobs.
O que cobre a perda do storage
Política de backup e restore testada ou um ambiente de disaster recovery. Com os jobs restaurados, o Hangfire executa os pendentes quando voltar. Isso entra na conta do time de plataforma, SRE ou NOC.

A execução durável depende do time de plataforma, de SRE ou, no mínimo, de um NOC monitorando essas peças. Se o servidor do Hangfire explodir e levar os jobs junto, o que salva é uma política de backup e restore testada, ou um ambiente de disaster recovery. Com os jobs restaurados, o Hangfire executa os pendentes quando voltar, conforme a política de execução, e nenhuma execução se perde. Esse custo operacional precisa entrar na conta antes de adotar o desenho, principalmente em time pequeno sem SRE dedicado.

Hibernação: o job que espera 3 dias

Hibernar, neste exemplo, é o Hangfire guardar um job agendado até a data em que ele precisa rodar. Quando o Autorizar dá certo, ele agenda o Expirar para daqui a 3 dias. Nesse intervalo, o que fica guardado é um registro no storage do Hangfire com a data de execução, sem thread ou processo parado esperando. Pode ser 3 dias, 5 dias ou duas semanas esperando uma confirmação.

[DisplayName("Expirar confirmação do pedido {0}")]
public async Task Expirar(Guid pedidoId, CancellationToken ct)
{
    // Só expira se o pedido ainda estiver parado em "autorizado".
    await _conn.ExecuteAsync(
        "UPDATE pedido SET status = 'autorizacao_expirada' WHERE id = @id AND status = 'autorizado'",
        new { id = pedidoId });
}

O WHERE status = 'autorizado' resolve a regra de negócio: se o pedido já foi confirmado, capturado ou entregue, o update não afeta nenhuma linha. O job, porém, continua agendado e roda na data dele mesmo assim.

Em volume, isso vira um ponto de atenção. Com 200 mil pedidos em 3 dias, são 200 mil jobs de expiração esperando, e a maioria vai encontrar um pedido que já avançou.

Hibernação
Todo pedido autorizado deixa um job esperando 3 dias
Com 200 mil pedidos em 3 dias, são 200 mil jobs de expiração agendados. A maioria vai encontrar o pedido já confirmado.
job que vai rodar à toa (pedido já confirmado)job que expira de fato
Opção A · guarda no próprio job
UPDATE … WHERE status = ‘autorizado’. Simples, mas o job roda mesmo sem nada a fazer.
Opção B · cancelar na confirmação
Guardar o id do job e chamar BackgroundJob.Delete. Menos jobs, mais uma operação e mais código para manter.

A alternativa é cancelar o job quando o pedido for confirmado. O BackgroundJob.Schedule devolve o id do job, que precisa ser guardado junto do pedido para depois chamar BackgroundJob.Delete. É mais uma operação, mais uma transação e mais código para manter. Nenhuma das duas opções é simples de desenvolver e manter, mas, com os cuidados certos, as duas resolvem.

Idempotência: autorizar duas vezes sem cobrar duas vezes

Com backup e restore, retry do Polly e retry do Hangfire, uma pergunta aparece: como garantir que a autorização não execute de novo quando o job for repetido? Ninguém quer autorizar o mesmo pagamento duas vezes porque o backup voltou.

É aqui que entra a idempotência. Uma operação é idempotente quando executar uma ou várias vezes produz o mesmo efeito de negócio. Para isso, cada chamada leva uma chave de idempotência, e quem recebe a chamada usa essa chave para reconhecer uma repetição.

No exemplo, a chave junta o id do pedido e o passo:

public static class ChaveIdempotencia
{
    public static string Criar(Guid pedidoId, string passo) => $"{pedidoId}:{passo}";
}

Como o id do pedido nunca se repete, toda chamada de autorizar para aquele pedido sai com a mesma chave, seja a primeira, um retry do Polly ou um replay depois de um restore. A chave vai no cabeçalho da requisição para o gateway:

using var req = new HttpRequestMessage(HttpMethod.Post, "/autorizacoes")
{
    Content = JsonContent.Create(new { pedido.Id, pedido.Valor })
};
req.Headers.Add("Idempotency-Key", chave);

var resposta = await _http.SendAsync(req, ct);
var resultado = await resposta.Content.ReadFromJsonAsync(ct);

_logger.LogInformation("Autorização do pedido {PedidoId}, duplicada: {JaExistia}",
    pedido.Id, resultado!.JaExistia);
Idempotência
A mesma chave na segunda chamada devolve a primeira autorização
Chave = id do pedido + passo. O id do pedido nunca se repete, então a chave também não.
Idempotency-Key8f3c…a21:autorizar
1ª chamada
Gatewaychave nova
Autoriza R$ 1.250,00jaExistia: false
2ª chamada (retry ou replay)
Gatewaychave já vista
Devolve a mesmajaExistia: true
A regra que monta a chave mora no seu código de negócio. O gateway (Stripe, AbacatePay ou outro) só cumpre a promessa se receber a mesma chave toda vez.

Na primeira chamada, o gateway autoriza e guarda a chave. Na segunda, reconhece a chave e devolve a autorização que já existia. No exemplo o servidor de autorização é simulado. Numa integração real, o gateway seria Stripe, AbacatePay ou outro que você já usa, e cada um documenta como trata a chave.

A regra que monta a chave mora no seu código de negócio, numa classe separada. O gateway só cumpre a promessa se receber a mesma chave toda vez.

Retry e replay são coisas diferentes

O Polly faz retry, backoff e circuit breaker, e só para erros transitórios. Tem outro limite: o retry vive na memória do processo. Se o processo cai durante a segunda tentativa, o retry acaba ali. Não existe mais Polly, não existe mais contagem de tentativas.

Quando o Hangfire reexecuta esse job depois, o assunto passa a ser replay: rodar o job de novo do começo e reaproveitar o que já foi concluído. No exemplo, o replay mora no executor de passo, antes do Polly:

public async Task ExecutarAsync(Guid pedidoId, string passo,
    Func> acao, CancellationToken ct)
{
    // 1. Replay: se o recibo do passo existe, devolve o resultado salvo.
    var (achou, salvo) = await TentarLerResultadoAsync(pedidoId, passo, ct);
    if (achou) return salvo!;

    // 2. Executa com retry.
    var chave = ChaveIdempotencia.Criar(pedidoId, passo);
    var resultado = await _pipeline.ExecuteAsync(async t => await acao(chave, t), ct);

    // 3. Grava o recibo: INSERT INTO passo_concluido (pedido_id, passo, resultado_json)
    await GravarResultadoAsync(pedidoId, passo, resultado, ct);
    return resultado;
}

O TentarLerResultadoAsync consulta a tabela passo_concluido pelo pedido e pelo passo e devolve o JSON do resultado. Se o recibo existe, o passo não executa de novo. Se não existe, executa dentro do Polly e grava o recibo no final.

Retry × replay
O retry vive na memória do processo, o replay lê o recibo no banco
O executor de passo consulta a tabela passo_concluido antes de executar qualquer coisa.
Job começapasso: capturar
SELECT resultadoFROM passo_concluido
WHERE pedido + passo
sim
Recibo existedevolve o resultado salvo, não executa
não
Executadentro do Polly
INSERTpasso_concluido
Janela de risco
Se o processo cai depois de executar e antes do INSERT, o passo roda de novo. Quem segura a duplicidade aí é a chave de idempotência.

Esse desenho também tem uma janela de risco. Se o processo cai depois de executar e antes do insert em passo_concluido, o recibo não existe e o passo roda de novo no replay. É exatamente nesse caso que a chave de idempotência evita a cobrança em dobro: o gateway recebe a mesma chave e devolve a autorização anterior. Replay e idempotência trabalham juntos, e nenhum dos dois sozinho fecha todos os casos.

Vale o custo?

Montar execução durável assim dá trabalho. Tem custo de mão de obra para desenvolver, custo operacional para manter Hangfire e banco no ar, e custo de infraestrutura. Cada peça que vimos pode falhar de um jeito diferente.

A conta é comparar esse custo com o custo do processo falhar: cobrar o cliente duas vezes, não cobrar, o Pix sair sem a nota, a nota sair sem o Pix. Cada processo precisa ser analisado com essa pergunta.

Também existe o caso em que execução durável não se aplica. Se o processo cabe numa transação, é síncrono com a tela e o usuário precisa do retorno na hora, sem possibilidade de virar assíncrono, ele fica como está. Execução durável exige execução assíncrona.

Banco e agendador em outras linguagens

O Postgres guardou o progresso no exemplo, mas a tabela de recibos funciona em MySQL, SQL Server, Oracle, MongoDB, Redis, DynamoDB ou num event store como o KurrentDB (antigo EventStoreDB). O requisito é gravar o resultado de cada passo de forma durável e conseguir ler pelo pedido e pelo passo.

No lugar do Hangfire, o que importa é o agendador persistir os jobs fora da memória do processo:

  • .NET: Quartz.NET com job store em banco, TickerQ
  • Node: Agenda, BullMQ com jobs atrasados
  • Ruby: Sidekiq com perform_in, que agenda o job no Redis
  • Go: River
  • Java e Kotlin: Quartz com JDBC job store, JobRunr
  • Python: Celery com countdown ou eta, APScheduler com job store em banco

Agendadores que guardam o cronograma só em memória, como o node-cron ou um @Scheduled do Spring, perdem o agendamento quando o processo reinicia e não servem para esperar 3 dias.

O mesmo processo com o skail

Este post sai no blog da skail, e o skail é a plataforma de execução durável que a gente constrói aqui. Fica o vínculo declarado antes de mostrar como o mesmo processo ficaria com ele.

Com o skail
O mesmo processo escrito como uma function
Retry, retomada, replay e espera de 3 dias saem do código da aplicação.
Sem plataforma
PollyHangfire ServerHangfire Scheduletabela pedido_jobtabela passo_concluidojob Expirar por pedidoencadeamento manualbackup do storage de jobs
Com skail
[SkailFunction] Processar
  await Autorizar // [SkailCommand]
  WaitForEvent(confirmação)
  Delay(3 dias)
  WhenAny → prazo? Estornar
  await Capturar // [SkailCommand]
  await EmitirNota // [SkailFunction]
  await Concluir
O código continua rodando na sua infraestrutura, no mesmo host onde a aplicação já roda hoje.
[SkailFunction]
public async SkailTask Processar(Guid pedidoId)
{
    var autorizacao = await Autorizar(pedidoId);

    var confirmacao = SkailTask.WaitForEvent(Eventos.ConfirmacaoPagamento, pedidoId.ToString());
    var prazo       = SkailTask.Delay(TimeSpan.FromDays(3));

    if (await SkailTask.WhenAny(confirmacao, prazo) == prazo)
    {
        await Estornar(pedidoId, autorizacao.Id);
        return;
    }

    await Capturar(pedidoId, autorizacao.Id);
    await EmitirNota(pedidoId);   // outra [SkailFunction], com histórico próprio
    await Concluir(pedidoId);
}

[SkailCommand]
public async SkailTask Autorizar(Guid pedidoId)
    => await _gateway.AutorizarAsync(pedidoId, ChaveIdempotencia.Criar(pedidoId, "autorizar"));

O Processar autoriza o pagamento e cria duas esperas: o evento externo de confirmação e o prazo de 3 dias. O WhenAny devolve a que terminar primeiro. Se o prazo vence, o fluxo estorna. Se a confirmação chega antes, captura, emite a nota e conclui.

O Autorizar e o Capturar são [SkailCommand], os métodos que fazem as operações de I/O do fluxo. A chamada ao gateway continua normal, igual ao código sem skail. A diferença é que o retry, a retomada de onde parou e o replay vêm junto, e o Delay hiberna a execução sem ocupar thread nem processo. Saem do código o Polly, o Hangfire, a tabela de recibos, o job de expiração por pedido e o encadeamento manual.

O código continua rodando na sua infraestrutura, no mesmo host onde a aplicação já roda hoje. Observabilidade e depuração com time travel ficam para outro post. Se quiser ver as primitivas usadas aqui, a referência do WaitForEvent tem um exemplo de espera com prazo, e o quickstart em C# mostra como começar. O trecho acima é um exemplo para comparar os dois desenhos.

Recapitulando

ConceitoOnde ficou no exemploLimite
RetryPolly no executor de passo, só para erro transitórioMorre junto com o processo
Registro de cada passoTabela pedido_job e jobs encadeados no HangfireDepende do Hangfire e do banco no ar
ReplayTabela passo_concluido lida antes de executarJanela entre executar e gravar o recibo
IdempotênciaChave pedidoId:passo no cabeçalho para o gatewayO serviço de destino precisa respeitar a chave
HibernaçãoBackgroundJob.Schedule de 3 diasUm job por pedido, mesmo quando não há nada a fazer

Perguntas frequentes

O retry do Polly não resolve sozinho? Resolve erro transitório dentro de um processo vivo. Se o processo cai, as tentativas pendentes somem. Para continuar depois de um restart, você precisa de replay, com o progresso salvo fora da memória.

Preciso do retry do Polly se o Hangfire já reexecuta jobs? São camadas diferentes. O Polly repete uma chamada em milissegundos, dentro do mesmo passo. O AutomaticRetry do Hangfire reexecuta o job inteiro, com intervalos maiores. Com replay no executor de passo, a reexecução do job pula o que já foi concluído.

Por que a chave de idempotência usa o passo além do id do pedido? Porque o mesmo pedido passa por autorização e captura no mesmo gateway. Com só o id do pedido, a captura chegaria com a chave da autorização e seria tratada como repetição.

Por onde começar na sua aplicação

Pegue um processo que hoje roda em background e fala com pelo menos um serviço externo. Marque quais passos alteram dados fora do seu banco, quais erros desses passos são transitórios e qual chave identifica cada chamada sem ambiguidade. Com essas três respostas, dá para decidir se o processo precisa de execução durável e quanto do desenho deste post ele precisa.