Le binaire ne parlait que français alors que le menu suit EL_LANG : un opérateur anglophone lisait un statut qu'il ne comprenait pas. Chaque message humain passe par T(), dont la clé EST le texte français, et un catalogue anglais le traduit ; la langue vient de --lang, puis d'EL_LANG, le français restant le défaut. Le menu demande le français pour ce qu'il RELIT, ses motifs ne connaissant que cette langue. Un test refuse un message sans traduction, une traduction orpheline et un verbe de format qui diverge. --- EN --- The binary spoke French only while the menu follows EL_LANG: an English reader got a status he could not read. Every human message goes through T(), whose key IS the French text, and an English catalogue translates it; the language comes from --lang, then EL_LANG, French remaining the default. The menu asks for French for what it READS BACK, its patterns knowing that language alone. A test refuses a message without translation, an orphan translation, and a format verb that drifts. Assisted-by: Claude Opus 5
221 lines
7.2 KiB
Go
221 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(
|
|
T("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 T("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")
|
|
}
|