222 lines
7.2 KiB
Go
222 lines
7.2 KiB
Go
|
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
|
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"net"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"syscall"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// DelaiEtablissement borne l'établissement d'une connexion vers l'amont, pour
|
||
|
|
// le relais comme pour la sonde du miroir git.
|
||
|
|
//
|
||
|
|
// Un pare-feu qui JETTE les paquets sans les refuser fait pendre
|
||
|
|
// l'établissement jusqu'à ce délai : c'est le prix que paie chaque tentative
|
||
|
|
// vers un amont coupé, et la raison d'être de la mémoire qui suit.
|
||
|
|
const DelaiEtablissement = 4 * time.Second
|
||
|
|
|
||
|
|
// FenetreMuetParDefaut : combien de temps un amont dont l'établissement vient
|
||
|
|
// d'échouer est tenu pour muet sans être retenté.
|
||
|
|
//
|
||
|
|
// Courte à dessein. Elle suffit à épargner le délai d'établissement aux
|
||
|
|
// dizaines de requêtes qui visent le même hôte en rafale, et elle rend l'amont
|
||
|
|
// à sa première chance dès qu'il revient.
|
||
|
|
const FenetreMuetParDefaut = 20 * time.Second
|
||
|
|
|
||
|
|
// SentinelleAmonts nomme le témoin que la levée d'une coupure touche dans le
|
||
|
|
// magasin pour annuler la mémoire des amonts muets. Le nom est tenu en accord
|
||
|
|
// avec le code qui pose la coupure : toucher un fichier que rien ne lit
|
||
|
|
// laisserait les amonts muets jusqu'à la fin de leur fenêtre.
|
||
|
|
const SentinelleAmonts = ".amonts-oublies"
|
||
|
|
|
||
|
|
// errAmontConnuMuet dit qu'aucune connexion n'a été tentée : l'établissement
|
||
|
|
// vers cet amont a échoué il y a moins d'une fenêtre.
|
||
|
|
var errAmontConnuMuet = errors.New(
|
||
|
|
"amont connu muet : l'établissement vient d'échouer, il n'est pas retenté")
|
||
|
|
|
||
|
|
// Joignabilite retient, par « hôte:port », l'instant du dernier
|
||
|
|
// établissement de connexion manqué.
|
||
|
|
//
|
||
|
|
// Sans elle, chaque requête vers un amont coupé repaie le délai
|
||
|
|
// d'établissement entier avant de rabattre sur la copie gardée : une
|
||
|
|
// installation hors ligne en enchaîne des centaines, et l'attente domine
|
||
|
|
// alors tout le reste. Avec elle, la première requête paie, les suivantes
|
||
|
|
// passent directement au repli.
|
||
|
|
//
|
||
|
|
// Seul un échec d'ÉTABLISSEMENT compte : délai de connexion, refus, hôte ou
|
||
|
|
// réseau injoignable. Un serveur lent à rendre ses en-têtes, une poignée de
|
||
|
|
// main TLS manquée ou un statut HTTP disent que l'amont est LÀ, et le tenir
|
||
|
|
// pour muet servirait une copie périmée à la place d'une réponse qui venait.
|
||
|
|
//
|
||
|
|
// Partagée entre le relais et le miroir git : un hôte coupé l'est pour les
|
||
|
|
// deux. Toutes les méthodes acceptent un récepteur nul, qui ne retient rien.
|
||
|
|
type Joignabilite struct {
|
||
|
|
// Fenetre borne la mémoire d'un échec. Nulle, la valeur par défaut
|
||
|
|
// s'applique.
|
||
|
|
Fenetre time.Duration
|
||
|
|
// Maintenant rend l'heure courante ; les tests la remplacent pour faire
|
||
|
|
// passer une fenêtre sans l'attendre.
|
||
|
|
Maintenant func() time.Time
|
||
|
|
// Sentinelle est un fichier dont la date annule la mémoire : tout échec
|
||
|
|
// antérieur est oublié. Il n'existe aucun canal vers le service en
|
||
|
|
// marche, et sans ce témoin les amonts notés muets PENDANT une coupure
|
||
|
|
// le restent jusqu'à la fin de la fenêtre — la première requête d'après
|
||
|
|
// la levée tombe alors dans le repli alors que le réseau est revenu.
|
||
|
|
// Vide, rien n'est consulté.
|
||
|
|
Sentinelle string
|
||
|
|
|
||
|
|
mu sync.Mutex
|
||
|
|
echecs map[string]time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// NouvelleJoignabilite rend une mémoire vide, à la fenêtre par défaut.
|
||
|
|
func NouvelleJoignabilite() *Joignabilite {
|
||
|
|
return &Joignabilite{Fenetre: FenetreMuetParDefaut}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j *Joignabilite) maintenant() time.Time {
|
||
|
|
if j.Maintenant != nil {
|
||
|
|
return j.Maintenant()
|
||
|
|
}
|
||
|
|
return time.Now()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (j *Joignabilite) fenetre() time.Duration {
|
||
|
|
if j.Fenetre > 0 {
|
||
|
|
return j.Fenetre
|
||
|
|
}
|
||
|
|
return FenetreMuetParDefaut
|
||
|
|
}
|
||
|
|
|
||
|
|
// ConnuMuet dit si l'établissement vers cette adresse a échoué depuis moins
|
||
|
|
// d'une fenêtre. Une entrée échue est oubliée au passage.
|
||
|
|
func (j *Joignabilite) ConnuMuet(adresse string) bool {
|
||
|
|
if j == nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
j.mu.Lock()
|
||
|
|
defer j.mu.Unlock()
|
||
|
|
quand, ok := j.echecs[adresse]
|
||
|
|
if !ok {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if j.maintenant().Sub(quand) >= j.fenetre() {
|
||
|
|
delete(j.echecs, adresse)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
// Le témoin touché après l'échec : la coupure a été levée depuis, et
|
||
|
|
// l'amont mérite une nouvelle chance immédiate. Un témoin illisible ou
|
||
|
|
// absent ne change rien — la fenêtre reprend seule son office.
|
||
|
|
if j.Sentinelle != "" {
|
||
|
|
if info, err := os.Stat(j.Sentinelle); err == nil &&
|
||
|
|
info.ModTime().After(quand) {
|
||
|
|
delete(j.echecs, adresse)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// Echec retient l'adresse si l'erreur est un échec d'établissement, et dit
|
||
|
|
// si elle l'a été. Toute autre erreur est ignorée.
|
||
|
|
func (j *Joignabilite) Echec(adresse string, err error) bool {
|
||
|
|
if j == nil || !estEchecDEtablissement(err) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
j.mu.Lock()
|
||
|
|
defer j.mu.Unlock()
|
||
|
|
if j.echecs == nil {
|
||
|
|
j.echecs = map[string]time.Time{}
|
||
|
|
}
|
||
|
|
j.echecs[adresse] = j.maintenant()
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reussite oublie l'adresse : l'amont vient de répondre.
|
||
|
|
func (j *Joignabilite) Reussite(adresse string) {
|
||
|
|
if j == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
j.mu.Lock()
|
||
|
|
defer j.mu.Unlock()
|
||
|
|
delete(j.echecs, adresse)
|
||
|
|
}
|
||
|
|
|
||
|
|
// estEchecDEtablissement dit si l'erreur vient de l'ÉTABLISSEMENT de la
|
||
|
|
// connexion, et non de ce qui la suit.
|
||
|
|
//
|
||
|
|
// La bibliothèque rend un établissement manqué comme un « *net.OpError »
|
||
|
|
// d'opération « dial », enveloppé par le client HTTP dans un « *url.Error » :
|
||
|
|
// errors.As traverse les deux. Le délai compte, et parmi les codes système
|
||
|
|
// ceux qui disent « personne ne répond à cette adresse ». Un nom qui n'existe
|
||
|
|
// pas n'en est pas : ce n'est pas un amont coupé, c'est un amont inexistant.
|
||
|
|
func estEchecDEtablissement(err error) bool {
|
||
|
|
var op *net.OpError
|
||
|
|
if err == nil || !errors.As(err, &op) || op.Op != "dial" {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if op.Timeout() {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
var errno syscall.Errno
|
||
|
|
if errors.As(op.Err, &errno) {
|
||
|
|
switch errno {
|
||
|
|
case syscall.ECONNREFUSED, syscall.EHOSTUNREACH,
|
||
|
|
syscall.ENETUNREACH, syscall.ETIMEDOUT:
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// adresseAmont rend la clé « hôte:port » d'une URL, le port déduit du schéma
|
||
|
|
// quand l'URL n'en porte pas : « https://h/x » et « https://h:443/y » visent
|
||
|
|
// la même machine et doivent partager leur mémoire.
|
||
|
|
func adresseAmont(u *url.URL) string {
|
||
|
|
if u == nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
hote := strings.ToLower(u.Hostname())
|
||
|
|
port := u.Port()
|
||
|
|
if port == "" {
|
||
|
|
switch strings.ToLower(u.Scheme) {
|
||
|
|
case "https":
|
||
|
|
port = "443"
|
||
|
|
case "http":
|
||
|
|
port = "80"
|
||
|
|
default:
|
||
|
|
return hote
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return net.JoinHostPort(hote, port)
|
||
|
|
}
|
||
|
|
|
||
|
|
// sonderTCP ouvre puis referme une connexion vers l'adresse, sans rien y
|
||
|
|
// dire. Rend l'erreur d'établissement, nulle quand l'amont a accepté.
|
||
|
|
func sonderTCP(ctx context.Context, adresse string) error {
|
||
|
|
d := net.Dialer{Timeout: DelaiEtablissement}
|
||
|
|
c, err := d.DialContext(ctx, "tcp", adresse)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return c.Close()
|
||
|
|
}
|
||
|
|
|
||
|
|
// decrireMuet rend la phrase du message hors ligne qui dit que l'amont n'a
|
||
|
|
// pas été retenté, ou "" quand la cause est un vrai échec du réseau.
|
||
|
|
func decrireMuet(cause error) string {
|
||
|
|
if !errors.Is(cause, errAmontConnuMuet) {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return "L'établissement vers cet amont vient d'échouer : aucune connexion\n" +
|
||
|
|
"n'a été tentée pour cette requête, l'amont sera retenté sous peu.\n"
|
||
|
|
}
|