- Dans la codebase Go,
net/httpcontient un commentaire indiquant qu’il est impossible de modifier la chaîne d’erreur"http: request body too large"renvoyée parMaxBytesError.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/rsaetinternal/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
MaxBytesErrorest 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 largemontrent 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/rsacrypto/rsa/rsa.gocontient dansEncryptOAEPun 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.goinclut aussi dansSignPSSun 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/weakinternal/weakindique que la toolchain interdit explicitement d’accéder à ce package et à la fonction de référence viago: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.parsede Python et learray.hde 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
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
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
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
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
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
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/go1compatPar exemple, dans plusieurs fonctions
GenerateKey, on lit un octet supplémentaire dans le flux aléatoire avecMaybeReadBytehttps://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/70468L’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/chacha8randOn 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...
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
MaybeReadByteest utilisé dans plusieurs fonctionsGenerateKey, mais cela ne semble pas être le cas pour ed25519Avant 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 à retenirCela dit, c’est bien que la documentation de
ed25519.GenerateKeypré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 figentComme la tristement célèbre A20 line (https://en.wikipedia.org/wiki/A20_line), on finit par devoir traîner ce comportement cassé éternellement
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
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înesLe 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
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êcheDe plus,
errors.Isa é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()En particulier dans la bibliothèque standard, il n’y a quasiment aucune excuse
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
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...
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 lecatchde la plupart des langages est une forme rudimentaire de pattern matchinghttps://learn.microsoft.com/en-us/dotnet/csharp/language-ref...
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
https://en.wikipedia.org/wiki/HTTP_referer
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
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 »
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
jsonisValidNumberindique sisest un littéral numérique JSON valideisValidNumberdevrait être un détail d’implémentation interne, mais des packages largement utilisés y accèdent vialinknameUn membre éminent du hall of shame est
github.com/bytedance/sonicQuelques 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