---
title: "O Retry Que Sobreviveu ao Seu Token: Temporal Fakes em Go"
url: https://tiarebalbi.com/pt-br/blog/temporal-fakes-go-token-expiry-retry
markdown: https://tiarebalbi.com/pt-br/blog/temporal-fakes-go-token-expiry-retry.md
description: "Um mock estático nunca expira, então esconde a corrida de expiração de token no retry. Reescrevo um teste instável em Go com um temporal fake e synctest."
author: "Tiarê Balbi Bonamini"
locale: pt-br
published: 2026-09-02
updated: 2026-09-02
category: "Distributed Systems"
translation: https://tiarebalbi.com/en/blog/temporal-fakes-go-token-expiry-retry
---
# O Retry Que Sobreviveu ao Seu Token: Temporal Fakes em Go

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](https://www.thoughtworks.com/radar/techniques/summary/temporal-fakes)). 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](https://martinfowler.com/bliki/TestDouble.html)). 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](https://abseil.io/resources/swe-book/html/ch13.html)). 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](https://go.dev/blog/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.

​

![](https://rso2zax703psuq0y.public.blob.vercel-storage.com/postscontent/1781758777843-zs3t15ogwb.jpeg)

## 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](https://nango.dev/blog/concurrency-with-oauth-token-refreshes/)). 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](https://testing.googleblog.com/2024/02/increase-test-fidelity-by-avoiding-mocks.html)). 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.
