Pular para o conteúdo principal
Distributed Systems10 min de leitura

O Retry Que Sobreviveu ao Seu Token: Temporal Fakes em Go

Uma vez entreguei um teste que mockava um token como sempre válido, e ele escondeu um retry que alcançou o provedor depois que o token expirou. Estas são minhas notas reconstruindo aquele teste em Go com um temporal fake — um provedor com estado que compartilha um relógio com o código — mais testing/synctest, para que um TTL de 30 segundos rode em microssegundos e a corrida finalmente fique vermelha.

Todos os Posts
2/4

Já entreguei um teste instável (flaky) do qual não me orgulho. Ele mockava o cliente de auth para retornar um token válido, chamava o código sob teste, e afirmava sucesso. Verde toda vez. O código que ele cobria então deixou cair um pagamento sem duplicação em produção porque uma requisição reenviada alcançou o provedor de auth depois que o token havia expirado. O teste não poderia ter pego isso. O mock não tinha conceito de expiração, e essa lacuna era estrutural, não um descuido que eu pudesse remendar com mais uma asserção.

A Thoughtworks deu um nome ao que estava faltando. O Technology Radar Vol 34 (abril de 2026) adicionou temporal fakes ao anel Assess: fakes que mantêm uma máquina de estados interna e evoluem ao longo do tempo simulado, em vez de um dublê estático que retorna o que quer que o teste tenha roteirizado (Technology Radar Vol 34). O exemplo deles é uma stack de observabilidade para data centers de GPU, onde você não pode superaquecer uma placa real para testar um alerta térmico. A versão de backend é mais corriqueira e morde com mais frequência: caches com TTL, renovações de lease, rotação de tokens, orçamentos de retry. Este é o post onde reescrevo um desses testes honestamente, em Go, usando um fake com estado e o pacote testing/synctest que chegou estável no Go 1.25.

A única coisa para levar: um temporal fake compartilha um relógio com o código sob teste e mantém o estado da dependência, de modo que um token que é válido quando você o lê pode expirar até o momento em que a chamada reenviada chega. Essa corrida é exatamente a falha que um mock estático não consegue representar.

Por que o mock estático não consegue ver o bug

Percorra a falha em câmera lenta. Um worker busca um token com TTL de 30 segundos. Ele faz uma chamada. O upstream retorna um 503 transitório, então o worker recua e tenta de novo. O backoff é exponencial: 10 segundos, depois 20. A terceira tentativa chega na marca dos 30 segundos. O token expirou um instante antes. O provedor retorna 401. Se o loop de retry trata 401 como fatal, a requisição falha; se o trata como transitório, ele gira até o orçamento acabar.

O mock no meu teste original respondia da mesma forma independentemente de quando a chamada chegava: token bom, aqui está seu 200. O tempo não existia em seu mundo. Na taxonomia de dublês de teste de Martin Fowler, um stub como esse retorna respostas prontas às chamadas feitas durante o teste, e nada mais (Test Double). Um stub que ignora o relógio é fiel a um provedor que nunca expira tokens. Tal provedor não existe.

Um fake é diferente em natureza. O grupo de testes da Google o enquadra como uma implementação leve que obedece ao mesmo contrato que a coisa real, só que com um atalho — um store em memória em vez de um banco de dados, digamos (Test Doubles at Google). O contrato de um provedor de token inclui a expiração. Um fake que honra esse contrato tem que rastrear quando cada token morre. Uma vez que faz isso, "válido quando lido, expirado quando o retry chega" deixa de ser um cenário que eu tenho que lembrar de roteirizar e vira algo que o fake produz por conta própria.

O relógio é a costura, não o mock

A razão pela qual isso costumava ser doloroso em Go é que exercitar um TTL de 30 segundos significava ou dormir 30 segundos reais — lento, e instável numa máquina de CI carregada — ou enfiar uma interface Clock falsa por cada ponto de chamada e cada biblioteca que lê o tempo. O primeiro é o teste que eu tinha. O segundo é invasivo o bastante para que eu continuasse não o fazendo.

O Go 1.25 removeu o dilema. O pacote testing/synctest, experimental no 1.24 e estável no 1.25 (o Run experimental foi deprecado no 1.25 e removido no Go 1.26 — use Test), roda uma função de teste dentro de uma "bolha" isolada (Testing concurrent code with testing/synctest). Dentro da bolha, o pacote time usa um relógio falso que começa à meia-noite UTC de 2000-01-01. O tempo só avança quando toda goroutine na bolha está duravelmente bloqueada — em um time.Sleep, um canal criado na bolha, um sync.WaitGroup.Wait, e uma curta lista de operações similares. Quando a única goroutine para em time.Sleep(10 * time.Second), o runtime salta o relógio 10 segundos à frente e a acorda. Sem espera de relógio de parede, sem instabilidade.

A parte elegante para um temporal fake: meu provedor falso também lê time.Now(). Se o fake vive dentro da mesma bolha, ele vê o mesmo relógio falso que os sleeps de backoff. O time.Sleep do worker avança o tempo simulado, e a verificação de expiração do fake observa esse avanço de graça. O relógio é a costura compartilhada, e eu não preciso injetar nada à mão.

O diagrama abaixo é o que esboço antes de escrever o fake: o estado do token ao longo do tempo simulado, com as tentativas de retry plotadas no mesmo eixo para que a colisão em t=30 seja óbvia.

O fake, o bug, e o teste que o pega

Aqui está a coisa toda em um único arquivo. É um provedor de token falso com um ciclo de vida de dois estados (válido, expirado) mais um pequeno contador de falhas transitórias, um loop de retry com o bug embutido, e o teste que fixa a falha.

go
package tokenretry

import (
	"errors"
	"testing"
	"testing/synctest"
	"time"
)

var (
	errExpired   = errors.New("auth: token expired")
	errTransient = errors.New("auth: upstream unavailable")
)

// fakeAuth is a temporal fake of a token provider. It holds a state
// machine — one token with an expiry instant — and answers each call
// against the clock at the moment the call lands, not a scripted reply.
type fakeAuth struct {
	expiresAt time.Time
	failsLeft int
}

// Issue mints a token valid for ttl from the current (fake) time.
func (a *fakeAuth) Issue(ttl time.Duration) string {
	a.expiresAt = time.Now().Add(ttl)
	return "tok-1"
}

// Call validates the token, then simulates a few transient upstream blips.
func (a *fakeAuth) Call(token string) error {
	if !time.Now().Before(a.expiresAt) {
		return errExpired
	}
	if a.failsLeft > 0 {
		a.failsLeft--
		return errTransient
	}
	return nil
}

// callWithRetry retries on transient errors with exponential backoff.
// The bug: it reads the token once and never rechecks expiry.
func callWithRetry(a *fakeAuth, token string, attempts int, backoff time.Duration) error {
	var err error
	for i := 0; i < attempts; i++ {
		if err = a.Call(token); err == nil {
			return nil
		}
		if !errors.Is(err, errTransient) {
			return err
		}
		time.Sleep(backoff)
		backoff *= 2
	}
	return err
}

func TestRetryOutlivesToken(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		a := &fakeAuth{failsLeft: 2}
		tok := a.Issue(30 * time.Second)

		err := callWithRetry(a, tok, 5, 10*time.Second)

		if !errors.Is(err, errExpired) {
			t.Fatalf("got %v, want errExpired", err)
		}
	})
}

Rode com go test -run TestRetryOutlivesToken no Go 1.25 ou mais novo.

Algumas linhas carregam o argumento. Call verifica time.Now().Before(a.expiresAt) primeiro — essa única comparação é toda a diferença em relação a um stub estático, porque faz a resposta depender de quando a chamada acontece. callWithRetry lê o token uma vez e nunca o revisita; esse é o bug de produção que estou reproduzindo, não uma artimanha. O teste emite um token de 30 segundos, deixa o loop falhar transitoriamente duas vezes, e os dois sleeps de backoff (10s depois 20s) carregam o tempo simulado exatamente até a fronteira de expiração. A terceira chamada chega em t=30 e retorna errExpired.

O resultado que importa é o tempo de relógio de parede. Na minha execução o teste reportou 0.00s enquanto avançava 30 segundos de tempo simulado. Com timers reais ele bloquearia por 30 segundos e ainda seria instável num runner lento. E o contraste com o mock é nítido: um stub retornando transient, transient, nil deixaria o callWithRetry ter sucesso na terceira tentativa e o teste passaria — verde, e cego para a corrida. O temporal fake amarra o sucesso ao relógio, então o mesmo loop falha do jeito que falha em produção.

A correção, uma vez que o teste está vermelho contra o bug real, é a parte fácil: limite o orçamento total de retry abaixo do TTL, ou renove o token antes de qualquer tentativa que cairia dentro de um buffer de skew — renove quando now >= expiresAt - skew em vez de esperar pela fronteira rígida, que é a orientação padrão para corridas de expiração de token sob desvio de relógio (Nango on OAuth refresh concurrency). O ponto do fake é que ele torna a versão quebrada observavelmente quebrada primeiro.

Onde os temporal fakes mentem para você

O Radar nomeia a armadilha na mesma frase em que apresenta a técnica: um fake conquista confiança apenas enquanto permanece fiel à dependência real, e um fake que desviou fabrica falsa confiança num pipeline verde. Meu fake modela a expiração como um instante limpo. Um provedor real tem desvio de relógio, uma janela de carência, rotação de refresh-token que pode invalidar o token antigo mais cedo, e respostas 5xx que não são os soluços transitórios que roteirizei. Cada lacuna é um bug que o fake alegremente deixará de pegar.

A defesa é um teste de contrato: uma suíte que roda tanto contra o fake quanto contra o provedor real, travando no comportamento que ambos devem compartilhar. A escrita da Google sobre fidelidade de teste defende o mesmo — um fake sem contrato é só um palpite que compila (Increase Test Fidelity By Avoiding Mocks). Trato o fake como o caminho rápido e o teste de contrato como a coisa que o mantém honesto.

Dois limites me impediram de exagerar. Primeiro, o synctest só avança o tempo quando as goroutines estão duravelmente bloqueadas, e as regras são específicas: mutexes não bloqueiam duravelmente, e I/O real de rede ou arquivo também não. Um fake que se esconde atrás de um socket real ou bloqueia em um mutex global mantido fora da bolha vai travar o relógio ou causar panic na bolha. O fake tem que viver em processo e se comunicar através de canais da bolha ou chamadas diretas — o que um temporal fake faz de qualquer forma. Segundo, esta técnica é sobre correção ao longo do tempo simulado, não resiliência. É uma ferramenta diferente da simulação determinística de rede: um simulador como o turmoil do Tokio dirige o escalonador e a rede para reproduzir uma corrida de partição byte por byte, enquanto um temporal fake modela a máquina de estados de uma dependência conforme o relógio se move. Recorro ao simulador quando o bug vive na ordenação da rede, e ao temporal fake quando ele vive no ciclo de vida de uma dependência.

Quando recorrer a isto, e quando não

  • Use um temporal fake quando a correção depende de quando uma chamada chega em relação ao estado de uma dependência: expiração de cache com TTL, janelas de renovação de lease, rotação de token, orçamentos de retry que podem ultrapassar um deadline, backoff que pode matar de fome um timeout.
  • Combine-o com testing/synctest (Go 1.25+) para que o time.Now() do fake e o time.Sleep do código compartilhem um relógio falso, e um teste de TTL de 30 segundos rode em microssegundos.
  • Sustente-o com um teste de contrato contra a dependência real, ou o fake vai derivar para a falsa confiança — o próprio alerta do Radar.
  • Recorra à simulação determinística quando o bug está na ordenação da rede ou no entrelaçamento do escalonador em vez do ciclo de vida de uma dependência.
  • Pule-o para dependências sem estado, ou onde um stub simples já diz a verdade. Fidelidade que você não precisa é manutenção da qual você vai se ressentir.

A versão honesta do meu teste original é mal mais longa que a desonesta. A diferença é uma única comparação contra um relógio compartilhado — e essa comparação é o que transforma "o mock disse que estava tudo bem" num teste que falha do jeito que a produção falha.

Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.

Posts Relacionados

Distributed Systems

Read-Your-Writes é um Contrato de Sessão, Não uma Configuração de Banco

Adicionei uma réplica de leitura e, em um dia, usuários reportaram edições que "não salvaram" — elas tinham salvado, mas as leituras correram contra o stream de replicação e perderam. Estas são minhas notas sobre por que read-your-writes é um contrato de sessão em vez de uma configuração de réplica, reproduzido com 50 ms de lag fixo em Go, e as três formas de carregá-lo — roteamento sticky, um token de versão estilo GTID e uma espera de bounded-staleness — comparadas em custo.

Distributed Systems

Relógios Lógicos Híbridos: Fazendo o Last-Write-Wins Significar a Escrita Mais Recente

O last-write-wins baseado em relógio de parede mantém a escrita do relógio mais rápido, e não a do evento mais recente — e descarta silenciosamente dados causalmente mais novos sob desvio (skew). Estas são minhas notas reconstruindo um Relógio Lógico Híbrido em Go: um timestamp de 64 bits, monotônico e causal, por que seu contador permanece limitado, e quanto isso custa em reinícios por incerteza no estilo do CockroachDB.

Backend

Capturando uma Race Condition de Retry com Uma Seed: Simulação Determinística em Rust usando turmoil

Eu tinha três testes de retry flaky que ninguém conseguia reproduzir em um laptop. Reescrevi um deles em Rust em cima do turmoil, o simulador determinístico do Tokio, e uma única seed de 8 bytes fixou a race condition de partição byte por byte. Estas são minhas anotações sobre o que a seed realmente controla, o que escapa dela e quando o teste de simulação determinística vale a pena.