diff --git a/apps/api/internal/service/cdi.go b/apps/api/internal/service/cdi.go index 85f0603..7181aaf 100644 --- a/apps/api/internal/service/cdi.go +++ b/apps/api/internal/service/cdi.go @@ -15,13 +15,17 @@ import ( const bcbCDIURL = "https://api.bcb.gov.br/dados/serie/bcdata.sgs.12/dados?formato=json&dataInicial=%s&dataFinal=%s" -// cdiRateCache guarda as taxas BCB em memória até as 23:59:59 do dia do fetch. +// cdiRateCache guarda as taxas BCB em memória. +// Dados válidos: expiram às 23:59:59 do dia do fetch. +// Erro BCB (dado ainda não publicado): retry após 15 minutos. type cdiRateCache struct { - mu sync.RWMutex - rates map[string]float64 // YYYY-MM-DD → taxa diária - minDate string - maxDate string - expiresAt time.Time + mu sync.RWMutex + rates map[string]float64 // YYYY-MM-DD → taxa diária + minDate string + maxDate string + expiresAt time.Time // EOD — só setado em sucesso + retryAfter time.Time // curto prazo — setado em erro BCB + errorRange [2]string // range que retornou erro } func (c *cdiRateCache) valid() bool { @@ -32,6 +36,14 @@ func (c *cdiRateCache) covers(startDate, endDate string) bool { return c.valid() && c.minDate != "" && startDate >= c.minDate && endDate <= c.maxDate } +// throttled retorna true se uma chamada BCB recente falhou e ainda não passou o retry window. +func (c *cdiRateCache) throttled(startDate, endDate string) bool { + return !c.retryAfter.IsZero() && + time.Now().Before(c.retryAfter) && + startDate >= c.errorRange[0] && + endDate <= c.errorRange[1] +} + func (c *cdiRateCache) get(startDate, endDate string) []bcbEntry { start, _ := time.Parse("2006-01-02", startDate) end, _ := time.Parse("2006-01-02", endDate) @@ -60,6 +72,13 @@ func (c *cdiRateCache) put(entries []bcbEntry, startDate, endDate string) { } now := time.Now() c.expiresAt = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) + // limpa throttle ao ter sucesso + c.retryAfter = time.Time{} +} + +func (c *cdiRateCache) markError(startDate, endDate string) { + c.retryAfter = time.Now().Add(15 * time.Minute) + c.errorRange = [2]string{startDate, endDate} } type CDIYieldService struct { @@ -176,6 +195,11 @@ func (s *CDIYieldService) fetchCDIRates(startDate, endDate string) ([]bcbEntry, log.Printf("[cdi] cache HIT [%s, %s] → %d entries", startDate, endDate, len(entries)) return entries, nil } + if s.cache.throttled(startDate, endDate) { + s.cache.mu.RUnlock() + log.Printf("[cdi] throttled [%s, %s] — retry após %s", startDate, endDate, s.cache.retryAfter.Format("15:04:05")) + return nil, nil + } log.Printf("[cdi] cache MISS [%s, %s] (minDate=%s maxDate=%s valid=%v)", startDate, endDate, s.cache.minDate, s.cache.maxDate, s.cache.valid()) s.cache.mu.RUnlock() @@ -185,6 +209,9 @@ func (s *CDIYieldService) fetchCDIRates(startDate, endDate string) ([]bcbEntry, if s.cache.covers(startDate, endDate) { return s.cache.get(startDate, endDate), nil } + if s.cache.throttled(startDate, endDate) { + return nil, nil + } start, _ := time.Parse("2006-01-02", startDate) end, _ := time.Parse("2006-01-02", endDate) @@ -202,10 +229,9 @@ func (s *CDIYieldService) fetchCDIRates(startDate, endDate string) ([]bcbEntry, Valor string `json:"valor"` } if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - // BCB retornou objeto de erro em vez de array (dado ainda não publicado) - // cachear resultado vazio até fim do dia para não bater no BCB a cada request - log.Printf("[cdi] BCB decode error para [%s, %s]: %v — cacheando vazio até 23:59", startDate, endDate, err) - s.cache.put(nil, startDate, endDate) + // BCB retornou objeto (dado ainda não publicado) — retry em 15 min + log.Printf("[cdi] BCB decode error para [%s, %s]: %v — retry em 15min", startDate, endDate, err) + s.cache.markError(startDate, endDate) return nil, nil }