chore: build
Backend - Build & Lint / build (push) Failing after 28m23s

This commit is contained in:
Nuxgrid
2026-07-12 15:20:53 +02:00
parent 2919bca86d
commit 4de42cff57
40 changed files with 2539 additions and 796 deletions
@@ -64,29 +64,12 @@ func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionServi
}
}
// ============================================
// POINT D'ENTRÉE PRINCIPAL
// ============================================
// ResolveAddress tente de géocoder une adresse avec correction automatique.
// Retourne toujours une suggestion, même approximative.
// Ordre de résolution :
// 1. Géocodage exact → succès immédiat
// 2. Nominatim fuzzy search (addressdetails + limit=5)
// 3. Décomposition structurée de l'adresse
// 4. Erreur explicite avec suggestions si dispo
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
rawAddress = strings.TrimSpace(rawAddress)
if rawAddress == "" {
return nil, fmt.Errorf("adresse vide")
}
// ── Étape 1 : essai exact (cache Redis puis Nominatim direct) ──
// Volontairement pas d'appel à acs.geoService.GeocodeAddress ici : cette
// méthode retombe elle-même sur ResolveAddress quand le géocodage direct
// échoue, ce qui provoquerait une récursion infinie GeocodeAddress <->
// ResolveAddress pour toute adresse nécessitant réellement une
// correction (le cas d'usage même de cette fonction).
if loc, err := acs.geoService.getFromCache(rawAddress); err == nil {
return &AddressSuggestion{
OriginalAddress: rawAddress,
@@ -122,11 +105,6 @@ func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*Address
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
}
// ============================================
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
// ============================================
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
variants := buildAddressVariants(address)
@@ -156,7 +134,6 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
}
// queryNominatim exécute une requête vers l'API Nominatim
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
query = strings.TrimSpace(query)
if query == "" {
@@ -212,7 +189,6 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
parts := parseAddressParts(address)
// Essai 1 : numéro + rue + ville (sans code postal)
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
@@ -222,7 +198,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
}
}
// Essai 2 : rue + code postal uniquement
if parts.streetName != "" && parts.postcode != "" {
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
@@ -232,7 +207,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
}
}
// Essai 3 : ville + code postal comme zone de repli
if parts.city != "" && parts.postcode != "" {
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
suggestions, err := acs.queryNominatim(q, 3)
@@ -1,291 +0,0 @@
package services
import "testing"
// Ces tests couvrent la partie pure de l'algorithme de correction d'adresse
// (normalisation, décomposition, score de confiance) — sans appel réseau à
// Nominatim (rate-limité à 1 req/s, non adapté à une suite de tests). Les
// méthodes qui interrogent Nominatim (nominatimFuzzySearch, structuredSearch,
// ResolveAddress) ne sont donc pas exercées ici.
func TestNormalize_RemovesAccentsAndNormalizesSpacing(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"accent simple", "Crébillon", "Crebillon"},
{"plusieurs accents", "Cours des 50 Otages à Nantes", "Cours des 50 Otages a Nantes"},
{"espaces multiples", "12 Rue de Verdun", "12 Rue de Verdun"},
{"déjà normalisé", "Rue de Verdun", "Rue de Verdun"},
{"cédille", "Façade", "Facade"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := normalize(c.input); got != c.want {
t.Errorf("normalize(%q) = %q, want %q", c.input, got, c.want)
}
})
}
}
// Les abréviations ne sont reconnues qu'avec leur point final (sauf "Rte ")
// — une adresse mal écrite sans point ne sera pas développée. Ce test
// documente ce comportement réel plutôt que de le supposer.
func TestExpandFrenchAbbreviations(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"Av. développé", "12 Av. de la Paix", "12 Avenue de la Paix"},
{"Bd. développé", "5 Bd. Jean Moulin", "5 Boulevard Jean Moulin"},
{"Rte avec espace développé", "Rte de Vannes", "Route de Vannes"},
{"Pl. développé", "3 Pl. Royale", "3 Place Royale"},
{"Bd sans point NON développé (limite connue)", "5 Bd Jean Moulin", "5 Bd Jean Moulin"},
{"pas d'abréviation", "12 Rue Crébillon", "12 Rue Crébillon"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := expandFrenchAbbreviations(c.input); got != c.want {
t.Errorf("expandFrenchAbbreviations(%q) = %q, want %q", c.input, got, c.want)
}
})
}
}
// Exemple tiré du commentaire du code source lui-même : une particule ("le")
// insérée dans un nom de rue peut faire échouer un géocodage exact.
func TestSimplifyStreetName_RemovesEmbeddedArticles(t *testing.T) {
input := "20 Rue Gabriel le Pan de Ligny"
want := "20 Rue Gabriel Pan Ligny"
if got := simplifyStreetName(input); got != want {
t.Errorf("simplifyStreetName(%q) = %q, want %q", input, got, want)
}
}
func TestSimplifyStreetName_LeavesShortAddressesUnchanged(t *testing.T) {
// La garde ne s'applique qu'en dessous de 5 mots ("3 Rue de la Paix" en
// fait exactement 5 et serait donc simplifiée, voir le test ci-dessus).
input := "3 Rue Crébillon"
if got := simplifyStreetName(input); got != input {
t.Errorf("simplifyStreetName ne doit pas modifier une adresse de moins de 5 mots: got=%q want=%q", got, input)
}
}
// Décomposition d'adresses de Nantes (44000), y compris des cas mal écrits :
// ville en minuscule (non détectée par l'heuristique de majuscule), code
// postal mal saisi (lettre au lieu d'un zéro).
func TestParseAddressParts_HandlesRealisticAndBadlyWrittenNantesAddresses(t *testing.T) {
cases := []struct {
name string
input string
wantNumber string
wantStreet string
wantPostcode string
wantCity string
}{
{
name: "adresse bien formée",
input: "12 Rue Crébillon 44000 Nantes",
wantNumber: "12",
wantStreet: "Rue Crébillon",
wantPostcode: "44000",
wantCity: "Nantes",
},
{
name: "ville en minuscule non détectée (limite connue)",
input: "3 place royale 44000 nantes",
wantNumber: "3",
wantStreet: "place royale nantes", // la ville minuscule reste fondue dans la rue
wantPostcode: "44000",
wantCity: "",
},
{
name: "code postal mal saisi (lettre O au lieu de zéro) non reconnu",
input: "8 Rue de Verdun 44OOO Nantes",
wantNumber: "8",
wantStreet: "Rue de Verdun 44OOO", // "44OOO" n'est pas un code postal valide, reste dans la rue
wantPostcode: "",
wantCity: "Nantes",
},
{
name: "particule intégrée au nom de rue",
input: "20 Rue Gabriel le Pan de Ligny 44000 Nantes",
wantNumber: "20",
wantStreet: "Rue Gabriel le Pan de Ligny",
wantPostcode: "44000",
wantCity: "Nantes",
},
{
name: "sans numéro de rue",
input: "Rue Crébillon 44000 Nantes",
wantNumber: "",
wantStreet: "Rue Crébillon",
wantPostcode: "44000",
wantCity: "Nantes",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := parseAddressParts(c.input)
if got.streetNumber != c.wantNumber {
t.Errorf("streetNumber = %q, want %q", got.streetNumber, c.wantNumber)
}
if got.streetName != c.wantStreet {
t.Errorf("streetName = %q, want %q", got.streetName, c.wantStreet)
}
if got.postcode != c.wantPostcode {
t.Errorf("postcode = %q, want %q", got.postcode, c.wantPostcode)
}
if got.city != c.wantCity {
t.Errorf("city = %q, want %q", got.city, c.wantCity)
}
})
}
}
func TestIsPostcode(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"44000", true},
{"44100", true},
{"44OOO", false}, // lettre O au lieu de zéro — typo réaliste
{"4400", false}, // trop court
{"440000", false}, // trop long
{"", false},
{"abcde", false},
}
for _, c := range cases {
if got := isPostcode(c.input); got != c.want {
t.Errorf("isPostcode(%q) = %v, want %v", c.input, got, c.want)
}
}
}
func TestIsNumeric(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"12", true},
{"0", true},
{"", false},
{"12b", false},
{"-1", false},
}
for _, c := range cases {
if got := isNumeric(c.input); got != c.want {
t.Errorf("isNumeric(%q) = %v, want %v", c.input, got, c.want)
}
}
}
// La distance de Levenshtein doit rester tolérante aux fautes de frappe
// courantes (lettre manquante, inversion) et normalize() doit annuler l'écart
// dû aux accents.
func TestLevenshteinRatio_TypoTolerance(t *testing.T) {
cases := []struct {
name string
a, b string
minWant float64
}{
{"faute de frappe simple (Nantse/Nantes)", "Nantse", "Nantes", 0.6},
{"lettre manquante (Verdun/Verdu)", "Verdu", "Verdun", 0.7},
{"identique", "Nantes", "Nantes", 1.0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := levenshteinRatio(c.a, c.b); got < c.minWant {
t.Errorf("levenshteinRatio(%q, %q) = %.2f, want >= %.2f", c.a, c.b, got, c.minWant)
}
})
}
}
func TestLevenshteinRatio_AccentDifferenceResolvedByNormalize(t *testing.T) {
a, b := "Crebillon", "Crébillon"
if levenshteinRatio(a, b) >= 1.0 {
t.Fatalf("précondition: %q et %q ne devraient pas être identiques sans normalisation", a, b)
}
if got := levenshteinRatio(normalize(a), normalize(b)); got != 1.0 {
t.Errorf("après normalize(), les deux formes doivent être identiques: ratio=%.2f", got)
}
}
// Le score de confiance doit favoriser nettement une suggestion proche de
// l'adresse saisie (même mal orthographiée) par rapport à une suggestion
// sans rapport.
func TestComputeConfidence_ScoresCloseMatchHigherThanUnrelated(t *testing.T) {
original := "12 Rue Crebillon 44000 Nantse" // fautes: pas d'accent + "Nantse"
closeMatch := "12 Rue Crébillon, 44000, Nantes"
unrelated := "1 Avenue des Champs-Élysées, 75008, Paris"
closeScore := computeConfidence(original, closeMatch, 0.5)
unrelatedScore := computeConfidence(original, unrelated, 0.5)
if closeScore <= unrelatedScore {
t.Errorf("score adresse proche (%.2f) devrait être supérieur au score adresse sans rapport (%.2f)", closeScore, unrelatedScore)
}
if closeScore < 0.40 {
t.Errorf("score adresse proche trop bas pour dépasser le seuil d'acceptation (0.40): got=%.2f", closeScore)
}
}
// buildAddressVariants doit inclure la forme sans accent et la forme avec
// abréviation développée pour une adresse mal écrite combinant les deux.
func TestBuildAddressVariants_IncludesNormalizedAndExpandedForms(t *testing.T) {
input := "12 Av. de la Paix 44000 Nantes" // abréviation, pas d'accent ici mais le principe se généralise
variants := buildAddressVariants(input)
if len(variants) < 2 {
t.Fatalf("attendu plusieurs variantes, got=%d: %v", len(variants), variants)
}
if variants[0] != input {
t.Errorf("la première variante doit être l'adresse originale: got=%q", variants[0])
}
foundExpanded := false
for _, v := range variants {
if v == "12 Avenue de la Paix 44000 Nantes" {
foundExpanded = true
}
}
if !foundExpanded {
t.Errorf("attendu une variante avec l'abréviation développée parmi: %v", variants)
}
// Pas de doublons.
seen := map[string]bool{}
for _, v := range variants {
if seen[v] {
t.Errorf("variante en double: %q dans %v", v, variants)
}
seen[v] = true
}
}
func TestFormatNominatimAddress_PrefersStructuredFieldsOverDisplayName(t *testing.T) {
s := NominatimSuggestion{
DisplayName: "12, Rue Crébillon, Nantes, Loire-Atlantique, France métropolitaine, France",
}
s.Address.HouseNumber = "12"
s.Address.Road = "Rue Crébillon"
s.Address.Postcode = "44000"
s.Address.City = "Nantes"
want := "12 Rue Crébillon, 44000, Nantes"
if got := formatNominatimAddress(s); got != want {
t.Errorf("formatNominatimAddress = %q, want %q", got, want)
}
}
func TestFormatNominatimAddress_FallsBackToDisplayNameWhenNoStructuredFields(t *testing.T) {
s := NominatimSuggestion{DisplayName: "Quelque part en France"}
if got := formatNominatimAddress(s); got != s.DisplayName {
t.Errorf("formatNominatimAddress sans champs structurés doit renvoyer DisplayName: got=%q want=%q", got, s.DisplayName)
}
}
+1 -11
View File
@@ -211,10 +211,6 @@ func (gs *GeoService) getCacheKey(address string) string {
return fmt.Sprintf("geocode:cache:%s", address)
}
// ============================================
// CALCULS GÉOGRAPHIQUES
// ============================================
// CalculateDistance calcule la distance entre deux points (formule Haversine)
func CalculateDistance(from, to Coordinates) float64 {
// Conversion en radians
@@ -223,7 +219,6 @@ func CalculateDistance(from, to Coordinates) float64 {
lat2Rad := toRadians(to.Latitude)
lon2Rad := toRadians(to.Longitude)
// Différences
dLat := lat2Rad - lat1Rad
dLon := lon2Rad - lon1Rad
@@ -239,13 +234,10 @@ func CalculateDistance(from, to Coordinates) float64 {
// CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback)
func CalculateETA(distanceKm float64) int {
// ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance
if distanceKm < 0.1 {
return MinETA // Très proche: minimum 3 minutes
return MinETA
}
// Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic)
// Plus réaliste que 30 km/h
travelTime := (distanceKm / 25.0) * 60.0
// Ajouter une marge pour le trafic (environ 20%)
@@ -262,8 +254,6 @@ func CalculateETA(distanceKm float64) int {
return totalMinutes
}
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
// Retourne (etaMinutes, distanceKm, error)
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
if len(tomTomKeys.keys) == 0 {
distance := CalculateDistance(from, to)
@@ -1,142 +0,0 @@
package services
import (
"math"
"testing"
)
// Repères réels de Nantes (44000) utilisés pour vérifier le calcul de
// distance/temps de trajet des commandes.
var (
placeRoyale = Coordinates{Latitude: 47.2148, Longitude: -1.5584}
gareNantes = Coordinates{Latitude: 47.2173, Longitude: -1.5426}
aeroportNantes = Coordinates{Latitude: 47.1532, Longitude: -1.6107}
)
func almostEqual(a, b, tolerance float64) bool {
return math.Abs(a-b) <= tolerance
}
func TestCalculateDistance_SamePointIsZero(t *testing.T) {
if got := CalculateDistance(placeRoyale, placeRoyale); got != 0 {
t.Errorf("distance entre un point et lui-même: got=%.4f want=0", got)
}
}
// Le long d'un même méridien (même longitude), la distance Haversine est
// exacte : 1° de latitude = R * (π/180) ≈ 111.19 km.
func TestCalculateDistance_OneDegreeLatitudeIsExact(t *testing.T) {
from := Coordinates{Latitude: 47.0, Longitude: -1.5536}
to := Coordinates{Latitude: 48.0, Longitude: -1.5536}
want := EarthRadiusKm * (math.Pi / 180.0)
got := CalculateDistance(from, to)
if !almostEqual(got, want, 0.01) {
t.Errorf("distance 1° de latitude: got=%.4f want=%.4f", got, want)
}
}
func TestCalculateDistance_IsSymmetric(t *testing.T) {
d1 := CalculateDistance(placeRoyale, gareNantes)
d2 := CalculateDistance(gareNantes, placeRoyale)
if !almostEqual(d1, d2, 0.0001) {
t.Errorf("la distance doit être symétrique: A->B=%.4f B->A=%.4f", d1, d2)
}
}
// Place Royale <-> Aéroport de Nantes : environ 8 km à vol d'oiseau.
func TestCalculateDistance_RealNantesLandmarks(t *testing.T) {
got := CalculateDistance(placeRoyale, aeroportNantes)
if got < 6 || got > 10 {
t.Errorf("distance Place Royale -> Aéroport Nantes hors plage réaliste: got=%.2f km, want=[6,10]", got)
}
}
func TestCalculateETA_VeryCloseReturnsMinETA(t *testing.T) {
cases := []float64{0, 0.01, 0.05, 0.099}
for _, d := range cases {
if got := CalculateETA(d); got != MinETA {
t.Errorf("CalculateETA(%.3f km): got=%d want=%d (MinETA)", d, got, MinETA)
}
}
}
// Formule : (distance/25 km/h)*60 min, +20% de marge trafic, arrondi par troncature.
func TestCalculateETA_MatchesFormulaForNormalDistance(t *testing.T) {
distanceKm := 10.0
travelTime := (distanceKm / 25.0) * 60.0
want := int(travelTime * 1.2)
got := CalculateETA(distanceKm)
if got != want {
t.Errorf("CalculateETA(%.1f km): got=%d want=%d", distanceKm, got, want)
}
}
func TestCalculateETA_VeryFarClampsToMaxETA(t *testing.T) {
if got := CalculateETA(1000); got != MaxETA {
t.Errorf("CalculateETA(1000 km): got=%d want=%d (MaxETA)", got, MaxETA)
}
}
// L'ETA ne doit jamais sortir de l'intervalle [MinETA, MaxETA], quelle que
// soit la distance fournie (y compris des valeurs aberrantes).
func TestCalculateETA_AlwaysWithinBounds(t *testing.T) {
distances := []float64{-5, 0, 0.05, 1, 5, 10, 50, 100, 500, 10000}
for _, d := range distances {
got := CalculateETA(d)
if got < MinETA || got > MaxETA {
t.Errorf("CalculateETA(%.2f): got=%d, hors bornes [%d,%d]", d, got, MinETA, MaxETA)
}
}
}
// Sans clé TomTom configurée (cas de cet environnement de test), le calcul
// doit retomber sur Haversine + CalculateETA, sans appel réseau.
func TestCalculateETAWithTomTom_FallsBackToHaversineWithoutAPIKey(t *testing.T) {
if len(tomTomKeys.keys) != 0 {
t.Skip("test valable uniquement sans clé TomTom configurée dans l'environnement")
}
wantDistance := CalculateDistance(placeRoyale, aeroportNantes)
wantETA := CalculateETA(wantDistance)
gotETA, gotDistance, err := CalculateETAWithTomTom(placeRoyale, aeroportNantes)
if err != nil {
t.Fatalf("CalculateETAWithTomTom (fallback): %v", err)
}
if gotDistance != wantDistance {
t.Errorf("distance fallback: got=%.4f want=%.4f", gotDistance, wantDistance)
}
if gotETA != wantETA {
t.Errorf("ETA fallback: got=%d want=%d", gotETA, wantETA)
}
}
func TestValidateCoordinates(t *testing.T) {
cases := []struct {
name string
coords Coordinates
wantErr bool
}{
{"Nantes valide", placeRoyale, false},
{"latitude limite haute valide", Coordinates{Latitude: 90, Longitude: 0}, false},
{"latitude limite basse valide", Coordinates{Latitude: -90, Longitude: 0}, false},
{"latitude trop haute", Coordinates{Latitude: 90.1, Longitude: 0}, true},
{"latitude trop basse", Coordinates{Latitude: -90.1, Longitude: 0}, true},
{"longitude limite haute valide", Coordinates{Latitude: 0, Longitude: 180}, false},
{"longitude trop haute", Coordinates{Latitude: 0, Longitude: 180.1}, true},
{"longitude trop basse", Coordinates{Latitude: 0, Longitude: -180.1}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := ValidateCoordinates(c.coords)
if c.wantErr && err == nil {
t.Error("attendu une erreur, reçu nil")
}
if !c.wantErr && err != nil {
t.Errorf("erreur inattendue: %v", err)
}
})
}
}
+2 -2
View File
@@ -42,7 +42,7 @@ func (s *LBTelegramService) IsConfigured() bool {
// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte.
// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3).
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
payload := map[string]interface{}{
payload := map[string]any{
"user_id": chatID,
"username": username,
"role": role,
@@ -68,7 +68,7 @@ func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) erro
// SendNotification envoie un message via la gateway LBTelegram.
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
func (s *LBTelegramService) SendNotification(userID int64, message string) error {
payload := map[string]interface{}{
payload := map[string]any{
"user_id": userID,
"message": message,
}
+4 -4
View File
@@ -64,7 +64,7 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
return fmt.Errorf("telegram non configuré")
}
payload := map[string]interface{}{
payload := map[string]any{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
@@ -107,11 +107,11 @@ func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, butt
row = append(row, map[string]string{"text": b[0], "url": b[1]})
}
payload := map[string]interface{}{
payload := map[string]any{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
"reply_markup": map[string]interface{}{
"reply_markup": map[string]any{
"inline_keyboard": [][]map[string]string{row},
},
}
@@ -147,7 +147,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error {
return fmt.Errorf("telegram non configuré")
}
payload := map[string]interface{}{
payload := map[string]any{
"url": webhookURL,
"allowed_updates": []string{"message"},
}
+1 -5
View File
@@ -68,27 +68,23 @@ func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*h
_, startIdx := m.currentKey()
for attempt := 0; attempt < n; attempt++ {
for attempt := range n {
idx := (startIdx + attempt) % n
key := m.keys[idx]
req, err := buildReq(key)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
m.rotate(idx)
continue
}
return resp, nil
}