2 points par GN⁺ 2024-11-23 | 1 commentaires | Partager sur WhatsApp
  • Dans la codebase Go, net/http contient un commentaire indiquant qu’il est impossible de modifier la chaîne d’erreur "http: request body too large" renvoyée par MaxBytesError.Error() à cause de la loi de Hyrum
  • La loi de Hyrum est le principe selon lequel, dès qu’une API a suffisamment d’utilisateurs, quelqu’un finit par dépendre de tout comportement observable, même s’il ne fait pas partie du contrat officiel
  • Même une chaîne qui paraît anodine, comme un message d’erreur, peut casser du code existant au moment où elle change si du code externe s’appuie sur sa formulation exacte
  • Dans Go, on trouve des commentaires similaires dans crypto/rsa et internal/weak, à propos du risque de figer le comportement d’un flux aléatoire ou une sémantique non stabilisée
  • Ce problème n’est pas propre à Go, donc les API et bibliothèques publiques doivent être conçues de façon à éviter que des comportements non intentionnels ne se figent de facto comme un standard

La loi de Hyrum observée dans le code Go

  • Dans net/http/request.go, MaxBytesError.Error() renvoie la chaîne suivante
    • "http: request body too large"
    • Le commentaire précise : “Due to Hyrum's law, this text cannot be changed.”
  • La loi de Hyrum est un principe nommé d’après Hyrum Wright, et hyrumslaw.com la définit ainsi
    • Si une API a suffisamment d’utilisateurs, alors, indépendamment de ce que promet le contrat, quelqu’un dépendra de tous les comportements observables du système
  • Le point central du cas MaxBytesError est que la formulation exacte du message d’erreur peut être utilisée par du code externe
    • Une petite modification de formulation peut casser du code existant
    • Les résultats de recherche pour http: request body too large montrent du code open source Go qui utilise cette chaîne

Autres cas dans des packages Go et des codebases externes

  • Dépendance au flux aléatoire dans crypto/rsa

    • crypto/rsa/rsa.go contient dans EncryptOAEP un commentaire lié à la loi de Hyrum
    • Cette fonction ne promet pas une exécution déterministe vis-à-vis du flux aléatoire, mais comme elle n’applique pas MaybeReadByte, quelqu’un pourrait dépendre du comportement actuel
    • crypto/rsa/pss.go inclut aussi dans SignPSS un commentaire dans le même esprit
    • Dans les deux cas, un nombre bien défini d’octets aléatoires est incorporé au chiffré ou à la signature d’une manière bien définie, ce qui est traité comme un engagement acceptable
  • Risque de figer la sémantique dans internal/weak

    • internal/weak indique que la toolchain interdit explicitement d’accéder à ce package et à la fonction de référence via go:linkname
    • La sémantique de ce package n’a pas suivi le processus de proposition, et exposer cette fonctionnalité pourrait figer la sémantique existante à cause de la loi de Hyrum
  • Un motif récurrent au-delà de Go

    • Les références à la loi de Hyrum ne se limitent pas à Go
    • Les résultats de recherche multilingues de grep.app montrent des cas dans plusieurs langages
    • Le urllib.parse de Python et le array.h de Pixar OpenUSD sont aussi des exemples de codebases concernées
    • L’évolution de JavaScript a elle aussi conduit à des cas où une dépendance massive à des comportements étranges et non intentionnels est devenue de facto un standard

Ce qu’il faut vérifier avant de modifier

  • Lors d’un changement de code, il faut prendre en compte non seulement l’API documentée, mais aussi les comportements observables auxquels du code externe peut dépendre
  • Il faut concevoir les systèmes dès le départ pour réduire la possibilité qu’une dépendance à des comportements non intentionnels s’installe

1 commentaires

 
GN⁺ 2024-11-23
Avis sur Hacker News
  • La loi de Hyrum est une observation utile, mais il ne faut pas s’y accrocher au point d’en tirer de mauvaises conclusions
    Le temps d’exécution total d’une fonction est lui aussi une propriété observable ; on pourrait donc considérer que même optimiser une fonction pour la rendre plus rapide constitue un changement cassant. Après tout, une file pourrait soudain se vider trop vite et provoquer un interblocage. Malgré tout, 99,99999999 % des utilisateurs apprécieront que leur code devienne plus rapide sans aucun effort
    Au final, ce qui constitue un changement cassant ne peut pas être un contrat technique, mais forcément un contrat social. Sinon, on ne pourrait littéralement plus rien changer. Les auteurs de bibliothèques doivent documenter les parties de l’API qui ne changent pas, agir raisonnablement et faire preuve d’empathie envers les utilisateurs ; les utilisateurs de bibliothèques doivent comprendre qu’ils assument la responsabilité de s’appuyer sur des interfaces non documentées comme dépendances critiques, et faire eux aussi preuve d’empathie envers les auteurs

    • Pour les auteurs de bibliothèques open source, je pense que tout ce qui précède est juste
      Mais vu sous un autre angle, la loi de Hyrum n’est ni un contrat technique ni un contrat social : c’est une propriété technique émergente qui apparaît dans les systèmes suffisamment utilisés
      La manière d’y répondre dépend du contexte social. Si vous maintenez un projet FOSS, vous livrez une optimisation qui rend 99,99 % des usages plus rapides et ne demande qu’à 0,01 % de corriger son code ou de migrer vers une nouvelle API. Dans une grande entreprise tech, il faut à la fois optimiser et ne rien casser du tout en interne, donc collaborer avec plusieurs équipes pour trouver un compromis. Dans une entreprise de logiciel d’entreprise, même si seulement 0,1 % casse, vous ne livrez pas si cet utilisateur fait partie des cinq plus gros contrats
    • Il m’est arrivé autrefois de réduire une routine très inefficace d’environ 100 secondes à 0,1 seconde, et cela a cassé le système de reporting
      L’auteur initial appelait plusieurs fonctions asynchrones, puis supposait qu’elles seraient toutes terminées au moment où l’ancienne routine lente se terminerait. Il m’a fallu énormément de temps pour comprendre exactement ce qui se passait
    • Dans les années 1980, ce genre de problème existait réellement
      C’est pour cela que les PC avaient un bouton turbo pour réduire la vitesse, et que les ordinateurs 8 bits n’ont pas augmenté leur cadence pendant dix ans alors qu’ils disposaient de CPU plus rapides. Aujourd’hui, presque tout tourne sur au moins deux CPU, donc on dépend rarement du temps d’exécution d’une fonction, sauf pour savoir si elle est suffisamment rapide. Même dans l’embarqué, après avoir vu des CPU uniques disparaître du marché, on essaie d’éviter ce type de dépendance
    • J’aimerais un jour faire un lightning talk sur une théière porteuse
      Ce serait l’histoire de la raison pour laquelle le statut HTTP 418 était devenu une dépendance critique dans une API interne, et pourquoi, compte tenu des contraintes, c’était le moins mauvais choix possible
    • Des choses comme le temps d’exécution total d’une fonction ne sont pas sous le contrôle de son auteur, donc ce raisonnement me semble presque absurde
      L’environnement d’exécution, la charge du système à ce moment-là, l’exécution du GC, etc. peuvent tous avoir une influence
      En résumé, je ne considère pas le comportement émergent produit par la machine comme une interface voulue ni comme une forme quelconque de contrat. Par conséquent, même si quelqu’un dépend d’un comportement non intentionnel, de même que corriger un bug subtil n’est pas considéré comme un changement cassant, je ne considérerais pas cela non plus comme un changement cassant
      Dans ce cas, cela me semble surtout être une preuve supplémentaire de l’engagement très fort de Go envers la rétrocompatibilité
  • Haha, c’est moi qui ai écrit le commentaire dans crypto/rsa. En Go, on prend vraiment très au sérieux la loi de Hyrum et la compatibilité descendante https://go.dev/doc/go1compat
    Par exemple, dans plusieurs fonctions GenerateKey, on lit un octet supplémentaire dans le flux aléatoire avec MaybeReadByte https://pkg.go.dev/crypto/internal/randutil#MaybeReadByte afin que l’algorithme ne soit pas figé. Pas plus tard qu’hier, on a reçu un signalement indiquant qu’une private key ECDSA contenant une clé publique nil fonctionnait auparavant mais plus maintenant ; il faudra probablement la faire refonctionner https://go.dev/issue/70468
    L’itération sur les maps utilise un ordre aléatoire afin de ne pas exposer l’implémentation interne. La sortie de rand.Rand étant considérée comme faisant partie de la promesse de compatibilité, il a fallu déployer pas mal d’efforts pour l’améliorer https://go.dev/blog/randv2 https://go.dev/blog/chacha8rand
    On discute en permanence de ce qu’il faut promettre dans la documentation et des comportements qu’il faut explicitement marquer comme « susceptibles de changer ». Parce qu’on sait que ce qui est documenté ne peut absolument pas être modifié, et que même ce qui n’est pas explicitement indiqué comme « susceptible de changer » sera probablement difficile à changer https://go-review.googlesource.com/c/go/+/598336/comment/5d6...

    • Le changement de l’ordre d’itération des maps aide à réduire les breaking changes futurs en empêchant de dépendre d’un ordre précis, mais au moment du changement c’était bien un breaking change pour le code qui dépendait de l’ancien comportement d’ordre
      Je pense tout de même que c’est un compromis valable. J’utilise beaucoup Go et j’apprécie sa forte compatibilité descendante, mais si cela donne aux développeurs de Go davantage de liberté pour améliorer les performances et ajouter des fonctionnalités, je suis prêt à accepter un taux légèrement plus élevé de breaking changes
      Quand on voit l’enfer que subissent les utilisateurs d’autres écosystèmes, Python par exemple, je ne pense pas être le seul à le penser
    • Il a été dit que MaybeReadByte est utilisé dans plusieurs fonctions GenerateKey, mais cela ne semble pas être le cas pour ed25519
      Avant l’arrivée de ed25519.NewKeyFromSeed(), c’était le seul moyen de dériver une clé publique Ed25519 à partir d’une private key, et je suis presque certain d’avoir écrit du code qui en dépendait. Ça ne me plaisait pas beaucoup, mais c’était la seule option, donc c’est facile à retenir
      Cela dit, c’est bien que la documentation de ed25519.GenerateKey précise que la sortie est déterministe. J’ai l’impression qu’ils ont vraiment bien travaillé pour identifier et préserver les comportements figés dans l’API crypto de Go, tout en évitant que de nouveaux comportements se figent
    • Le cas de la clé nil fait se demander dans quelle mesure il est raisonnable de prendre en charge ce genre de situation
      Comme la tristement célèbre A20 line (https://en.wikipedia.org/wiki/A20_line), on finit par devoir traîner ce comportement cassé éternellement
    • Ironiquement, j’ai autrefois écrit un équilibreur de charge en Go qui dépendait de l’ordre aléatoire d’itération des maps
    • C’est l’un des aspects les plus sous-estimés de Go. Du code écrit il y a 12 ans fonctionne encore tout simplement
  • La solution au problème mentionné concrètement est de ne pas utiliser des erreurs basées sur des chaînes, mais des erreurs sentinelles https://thomas-guettler.de/go/wrapping-and-sentinel-errors
    Plus généralement, il ne faut pas écrire du code qui donne aux consommateurs de l’API la moindre envie de dépendre de chaînes non techniques. Si l’on utilise des éléments de premier ordre du langage, comme des valeurs d’erreur prédéfinies, des types ou des constantes contenant des chaînes non techniques, les consommateurs de l’API peuvent comparer la valeur de retour à une constante au lieu de coder une chaîne en dur
    La loi de Hyrum existe clairement, mais on peut en réduire l’impact

    • Ce qui est agaçant, c’est que l’erreur en question est déjà une erreur sentinelle
      Grafana, qui semble être la cause principale dans la recherche liée, aurait dû utiliser errors.As(&http.MaxBytesError{}) au lieu de comparer des chaînes
      Le point essentiel de la loi de Hyrum, c’est que la qualité de conception de l’API importe peu. Les gens finissent par dépendre du comportement, pas du contrat
    • Dans cet exemple, la responsabilité incombe au consommateur, pas au fournisseur
      On peut toujours écrire du code qui vérifie err.String() == "no more tea available.". Je suis d’accord qu’il ne faut pas le faire, mais rien ne l’empêche
      De plus, errors.Is a été ajouté relativement récemment à Go, donc au moment où les gens vérifiaient les erreurs de cette façon, vérifier une chaîne littérale était plus simple. En Go, le fournisseur d’une API ne peut pas empêcher le consommateur de vérifier la valeur renvoyée par .String()
    • Il y a quelques années, la comparaison d’erreurs par chaîne était la seule façon de faire cela, et Go a une promesse de compatibilité descendante
    • Le code qui vérifie les chaînes d’erreur brutes est tout simplement du mauvais code, et devrait être exclu des garanties de compatibilité descendante de Go
      En particulier dans la bibliothèque standard, il n’y a quasiment aucune excuse
    • Le problème vient de la conception initiale de Go. Pendant longtemps, les erreurs basées sur des chaînes étaient la seule méthode, et si ma mémoire est bonne, il en reste encore dans certains paquets de la bibliothèque standard, sans parler de tout l’écosystème
      Voilà ce qui arrive quand on ignore délibérément l’histoire des langages de programmation et qu’on adopte une approche du type « concevons au fur et à mesure »
  • La façon de lutter contre la loi de Hyrum est aussi un sujet intéressant
    Une possibilité consiste à introduire du hasard dans les parties dont on ne veut pas que les gens dépendent
    Si ma mémoire est bonne, le protocole QUIC fait cela. Dans la version actuelle, il y a un champ qui n’est pas utilisé, mais pour éviter que les routeurs ne commencent à identifier les paquets à partir de ce champ, la spécification exige qu’il soit défini à une valeur aléatoire plutôt qu’à un octet nul
    La source est probablement ici : https://www.rfc-editor.org/rfc/rfc9000#section-17.2.1
    « La valeur du champ Unused est définie par le serveur à une valeur arbitraire. Un client DOIT ignorer la valeur de ce champ. [...] Notez que d’autres versions de QUIC pourraient ne pas faire de recommandation similaire »
    Je crois que cela s’appelle le greasing, et que c’est destiné à empêcher l’ossification

    • GREASE est un acronyme créé dans la RFC 8701, qui signifie « Generate Random Extensions And Sustain Extensibility », et il a d’abord été utilisé dans le contexte de TLS
      https://www.rfc-editor.org/rfc/rfc8701.html
      Le tout premier brouillon de cette RFC remonte au milieu de 2016, ce qui est probablement la première apparition publique du terme : https://datatracker.ietf.org/doc/html/draft-davidben-tls-gre...
    • Excellent. Je connais plutôt bien QUIC, mais j’ignorais cela
      Il n’y a rien de pire que de se réveiller 10 ans plus tard, de découvrir qu’on a vraiment besoin de ces bits, et que 20 modèles de routeurs de 10 marques ont décidé que ces bits devaient obligatoirement avoir une forme précise
      Points bonus s’il y a, à l’autre bout, une somme de contrôle ou du chiffrement qui casse dès que les bits sont modifiés. Les « hacks intelligents » des middleboxes sont vraiment une plaie
  • C’est un bon exemple de logiciel stringly typed
    Les concepteurs de Go ne voulaient pas d’exceptions, mais avec panic/recover, il existe tout de même quelque chose de similaire, et les erreurs non typées sont nuisibles. À l’inverse, comment gérer des erreurs typées sans pattern matching ? Car le catch de la plupart des langages est une forme rudimentaire de pattern matching
    https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...

    • Go dispose d’erreurs typées. Elles n’ont simplement pas été utilisées dans ce cas
  • Dans un ancien emploi, j’avais repéré une faute de frappe dans un message d’erreur et je l’avais corrigée, avant de découvrir que le réseau de dépendances reposant sur ce texte fautif était si profond qu’il était en pratique impossible de le corriger ; nous avons finalement dû revenir au texte avec la faute
    Ça me travaille encore

  • C’est une sorte de loi de Hyrum, mais en réalité c’est juste du Go pur jus
    Si l’erreur avait été un type enum, les consommateurs auraient pu s’adapter avec une simple substitution de chaîne. À la place, comme on utilise des chaînes comme des types, il devient impossible de savoir de quelle manière les consommateurs en dépendent. Il suffit qu’ils vérifient seulement 6 caractères au milieu de la chaîne d’erreur pour que toute modification casse quelque chose
    C’est encore une décision de conception terrible et anachronique, alors que de meilleures alternatives existent dans d’autres langages depuis des décennies. Quand les erreurs initiales se combinent à l’impossibilité de changer, on reste coincé pour toujours

    • Malheureusement, ce commentaire est fondamentalement faux. Dans de nombreux cas, la chaîne elle-même était l’API officielle
  • Il est intéressant de noter que cette loi est exactement l’opposé du principe de robustesse, c’est-à-dire la loi de Postel
    « Soyez conservateur dans ce que vous envoyez, soyez libéral dans ce que vous acceptez »
    Si vous acceptez les entrées de manière libérale, vous devez comprendre de quelles façons vous avez été libéral et au moins les documenter en interne. À cause de la loi de Hyrum, même après de gros changements dans la base de code, vous finissez par devoir prendre en charge toutes ces façons pour toujours
    C’est précisément pour cette raison que j’essaie de ne pas créer d’API « libérales dans ce qu’elles acceptent »

    • Je préfère aussi cette approche
      Si l’on assouplit les critères des données acceptées par une API, il faut tôt ou tard décider comment transformer ces données en une forme canonique. Et cette décision semble presque toujours conduire, d’une manière ou d’une autre, à un comportement surprenant pour l’utilisateur
  • Les auteurs de packages semblent accepter ce problème à des degrés divers. Il y a quelques jours, j’ai vu ce commentaire dans le package json
    isValidNumber indique si s est un littéral numérique JSON valide
    isValidNumber devrait être un détail d’implémentation interne, mais des packages largement utilisés y accèdent via linkname
    Un membre éminent du hall of shame est github.com/bytedance/sonic

  • Quelques leçons apprises en publiant des API
    Les clients feront tout ce qu’il faut pour accomplir leur tâche, même si ce n’est pas la manière prévue par l’éditeur. Les clients ne lisent pas la documentation. Si suffisamment de clients dépendent d’un comportement, même un bug devient une partie de l’API. Le nombre d’appels API ne correspond pas forcément à l’importance
    C’est pourquoi, quand je développe une API, j’essaie de publier une API bêta le plus tôt possible, puis d’observer comment elle est utilisée afin de réduire les surprises. Dans la plupart des cas, j’incrémente la version majeure tout en prenant en charge la version précédente. Pour cela, il faut définir le SLA de l’API