2 Punkte von GN⁺ 2024-11-23 | 1 Kommentare | Auf WhatsApp teilen
  • In der Go-Codebasis von net/http gibt es einen Kommentar, dass der von MaxBytesError.Error() zurückgegebene Fehlerstring "http: request body too large" wegen Hyrum's Law nicht geändert werden kann
  • Hyrum's Law ist das Prinzip, dass bei genügend vielen API-Nutzern irgendjemand selbst von beobachtbarem Verhalten abhängig wird, das nicht Teil des offiziellen Vertrags ist
  • Selbst Strings, die trivial wirken, wie Fehlermeldungen, können in externem Code exakt ausgewertet werden; in dem Moment, in dem sie geändert werden, kann bestehender Code brechen
  • Innerhalb von Go gibt es ähnliche Kommentare auch in crypto/rsa und internal/weak; dort geht es um das Risiko, dass das Verhalten von Zufalls-Streams oder nicht festgelegte Semantik faktisch festgeschrieben wird
  • Das Problem ist nicht auf Go beschränkt, daher sollten öffentliche APIs oder Bibliotheken so entworfen werden, dass unbeabsichtigtes Verhalten nicht de facto zum Standard erstarrt

Hyrum's Law im Go-Code nachgewiesen

  • net/http/request.go gibt in MaxBytesError.Error() den folgenden String zurück
    • "http: request body too large"
    • Im zugehörigen Kommentar steht: “Due to Hyrum's law, this text cannot be changed.”
  • Hyrum's Law ist ein nach Hyrum Wright benanntes Prinzip; die Definition auf hyrumslaw.com lautet wie folgt
    • Wenn eine API genügend Nutzer hat, wird unabhängig davon, was der Vertrag verspricht, irgendjemand von jedem beobachtbaren Verhalten des Systems abhängig
  • Der Kern des MaxBytesError-Falls ist, dass die exakte Formulierung der Fehlermeldung in externem Code verwendet werden kann
    • Schon kleine Änderungen am Wortlaut können bestehenden Code brechen
    • In den Suchergebnissen für http: request body too large lässt sich Go-Open-Source-Code finden, der genau diesen String verwendet

Weitere Beispiele aus Go-Paketen und externen Codebasen

  • Abhängigkeit von Zufalls-Streams in crypto/rsa

    • crypto/rsa/rsa.go enthält bei EncryptOAEP einen Kommentar zu Hyrum's Law
    • Die Funktion verspricht keine deterministische Ausführung in Bezug auf den Zufalls-Stream, aber da MaybeReadByte nicht angewendet wird, könnte jemand vom aktuellen Verhalten abhängig sein
    • crypto/rsa/pss.go enthält bei SignPSS einen Kommentar im gleichen Kontext
    • In beiden Fällen werden zufällige Bytes in wohldefinierter Anzahl auf wohldefinierte Weise in den Chiffretext oder die Signatur aufgenommen, weshalb dies als tragbare Zusage behandelt wird
  • Risiko der Festschreibung von Semantik in internal/weak

    • internal/weak vermerkt, dass die Toolchain den Zugriff auf dieses Paket und die Referenzfunktion über go:linkname ausdrücklich verbietet
    • Die Semantik dieses Pakets hat kein Proposal-Verfahren durchlaufen; würde die Funktionalität offengelegt, könnte Hyrum's Law dazu führen, dass die bestehende Semantik festgeschrieben wird
  • Ein wiederkehrendes Muster auch außerhalb von Go

    • Hinweise auf Hyrum's Law sind nicht auf Go beschränkt
    • In den sprachübergreifenden Suchergebnissen auf grep.app finden sich Beispiele aus vielen Sprachen
    • Auch Pythons urllib.parse und Pixars OpenUSD-array.h sind Beispiele aus verwandten Codebasen
    • Auch die Entwicklung von JavaScript führte dazu, dass eine breite Abhängigkeit von diversen merkwürdigen und unbeabsichtigten Verhaltensweisen de facto zum Standard wurde

Was vor Änderungen geprüft werden sollte

  • Bei Codeänderungen sollte nicht nur die dokumentierte API berücksichtigt werden, sondern auch beobachtbares Verhalten, von dem externer Code abhängig sein könnte
  • Es braucht Systemdesign, das die Möglichkeit von Abhängigkeiten von unbeabsichtigtem Verhalten von Anfang an reduziert

1 Kommentare

 
GN⁺ 2024-11-23
Meinungen auf Hacker News
  • Hyrums Gesetz ist eine nützliche Beobachtung, aber man sollte sich nicht daran festbeißen und daraus die falschen Schlüsse ziehen.
    Auch die gesamte Laufzeit einer Funktion ist eine beobachtbare Eigenschaft, daher könnte selbst eine Optimierung, die eine Funktion schneller macht, als Breaking Change gelten. Schließlich könnte plötzlich eine Queue zu schnell geleert werden und dadurch ein Deadlock entstehen. Trotzdem würden 99,99999999 % der Nutzer es begrüßen, wenn ihr Code ohne eigenes Zutun schneller wird.
    Letztlich kann die Frage, was ein Breaking Change ist, nur ein sozialer Vertrag sein, kein technischer. Andernfalls könnte man buchstäblich gar nichts mehr ändern. Autoren von Libraries sollten dokumentieren, welche Teile ihrer API unverändert bleiben, vernünftig handeln und Empathie für ihre Nutzer haben; Nutzer von Libraries sollten verstehen, dass sie auf eigenes Risiko handeln, wenn sie undokumentierte Schnittstellen zu zentralen Abhängigkeiten machen, und ebenfalls Empathie für die Autoren haben.

    • Für Autoren von Open-Source-Libraries halte ich das alles für richtig.
      Aus einer anderen Perspektive ist Hyrums Gesetz jedoch weder ein technischer noch ein sozialer Vertrag, sondern eine emergente technische Eigenschaft, die in hinreichend weit verbreiteten Systemen auftritt.
      Wie man mit dieser Eigenschaft umgeht, hängt vom sozialen Kontext ab. Als FOSS-Maintainer würde man eine Optimierung ausliefern, wenn sie 99,99 % schneller macht und nur 0,01 % ihren Code anpassen oder auf eine neue API wechseln müssen. In einem großen Technologieunternehmen muss man die Optimierung ebenfalls vornehmen, darf aber intern 0 % brechen, also arbeitet man mit mehreren Teams zusammen und sucht einen Kompromiss. Ein Anbieter von Unternehmenssoftware würde sie nicht ausliefern, wenn auch nur 0,1 % betroffen sind und dieser Nutzer zu den fünf größten Verträgen gehört.
    • Ich habe einmal eine sehr ineffiziente Routine von etwa 100 Sekunden auf 0,1 Sekunden verkürzt, und dadurch ist ein Reporting-System kaputtgegangen.
      Der ursprüngliche Autor hatte mehrere asynchrone Funktionen aufgerufen und angenommen, dass sie alle abgeschlossen sein würden, wenn die früher langsame Routine fertig war. Es hat ewig gedauert herauszufinden, was genau passiert war.
    • In den 1980er-Jahren gab es solche Probleme tatsächlich.
      Deshalb hatten PCs einen Turbo-Button, der die Geschwindigkeit reduzierte, und 8-Bit-Computer wurden ein Jahrzehnt lang nicht schneller gemacht, obwohl es schnellere CPUs gab. Heute läuft fast alles auf zwei oder mehr CPUs, daher verlässt man sich kaum noch auf die Laufzeit einer Funktion, abgesehen davon, ob sie schnell genug ist. Auch im Embedded-Bereich versucht man solche Abhängigkeiten zu vermeiden, nachdem man erlebt hat, dass eine einzelne CPU abgekündigt wird.
    • Irgendwann möchte ich einen Lightning Talk über eine load bearing teapot halten.
      Es geht um die Geschichte, warum wir HTTP Status 418 in einer internen API zu einer zentralen Abhängigkeit gemacht haben und weshalb das unter den gegebenen Einschränkungen die am wenigsten schlechte Wahl war.
    • Etwas wie die gesamte Laufzeit einer Funktion liegt nicht unter der Kontrolle des Funktionsautors, daher wirkt diese Argumentation beinahe absurd.
      Die Betriebsumgebung, die aktuelle Systemlast, GC-Läufe und anderes können sich alle darauf auswirken.
      Kurz gesagt: Ich betrachte emergentes Verhalten, das aus der Maschine entsteht, nicht als beabsichtigte Schnittstelle oder als irgendeine Art von Vertrag. Wenn also jemand von unbeabsichtigtem Verhalten abhängig war, gilt das für mich ebenso wenig als Breaking Change, wie das Beheben eines subtilen Bugs als Breaking Change gilt.
      In diesem Fall scheint es vor allem eher ein Beleg dafür zu sein, wie stark Go sich zur Abwärtskompatibilität verpflichtet.
  • Haha, den Kommentar in crypto/rsa habe ich geschrieben. In Go nimmt man Hyrums Gesetz und Abwärtskompatibilität https://go.dev/doc/go1compat wirklich ernst.
    Zum Beispiel lesen mehrere GenerateKey-Funktionen mit MaybeReadByte https://pkg.go.dev/crypto/internal/randutil#MaybeReadByte ein zusätzliches Byte aus dem Zufallsstrom, damit der Algorithmus nicht festgelegt wird. Erst gestern kam ein Bericht herein, dass ein privater ECDSA-Key mit nil-Public-Key früher funktioniert hat und jetzt nicht mehr; vermutlich müssen wir das wieder zum Laufen bringen https://go.dev/issue/70468.
    Map-Iteration verwendet eine zufällige Reihenfolge, damit die interne Implementierung nicht offengelegt wird. Die Ausgabe von rand.Rand gilt als Teil des Kompatibilitätsversprechens, daher mussten wir einiges an Aufwand treiben, um sie zu verbessern https://go.dev/blog/randv2 https://go.dev/blog/chacha8rand.
    Wir diskutieren ständig, welche Zusagen wir in die Dokumentation schreiben und welches Verhalten wir ausdrücklich als „kann sich ändern“ kennzeichnen. Denn wir wissen: Was dokumentiert ist, kann niemals geändert werden, und auch Dinge, bei denen nicht ausdrücklich „kann sich ändern“ steht, lassen sich wahrscheinlich nur schwer ändern https://go-review.googlesource.com/c/go/+/598336/comment/5d6....

    • Die Änderung der Map-Iterationsreihenfolge hilft, künftige Breaking Changes zu reduzieren, indem sie verhindert, dass man sich auf eine bestimmte Reihenfolge verlässt; zum Zeitpunkt der Änderung war sie aber selbst ein Breaking Change für Code, der sich auf das frühere Reihenfolgeverhalten verlassen hatte.
      Trotzdem halte ich das für einen sinnvollen Kompromiss. Ich nutze Go viel und mag die starke Abwärtskompatibilität, aber wenn Go-Entwickler dadurch mehr Freiheit bekommen, Performance zu verbessern und Features hinzuzufügen, würde ich auch einen etwas höheren Anteil an Breaking Changes in Kauf nehmen.
      Wenn man sieht, welche Hölle Nutzer anderer Ökosysteme mitmachen, etwa bei Python, glaube ich nicht, dass ich mit dieser Ansicht allein bin.
    • Es hieß, MaybeReadByte werde in mehreren GenerateKey-Funktionen verwendet, aber bei ed25519 scheint das nicht der Fall zu sein.
      Bevor es ed25519.NewKeyFromSeed() gab, war das der einzige Weg, aus einem Private Key einen Public Ed25519 Key abzuleiten, und ich bin mir ziemlich sicher, dass ich Code geschrieben habe, der sich darauf verlassen hat. Es gefiel mir nicht besonders, aber es war das Einzige, was ging, daher ist es leicht zu merken.
      Gut ist allerdings, dass die Dokumentation von ed25519.GenerateKey ausdrücklich sagt, dass die Ausgabe deterministisch ist. Insgesamt scheint Go sehr gute Arbeit darin geleistet zu haben, festgeschriebenes Verhalten in den Kryptografie-APIs zu untersuchen und beizubehalten und neue Verfestigungen zu verhindern.
    • Der Fall mit dem nil key lässt einen daran zweifeln, wie vernünftig es ist, sogar solche Fälle zu unterstützen.
      Wie bei der berüchtigten A20-Line (https://en.wikipedia.org/wiki/A20_line) muss man dieses kaputte Verhalten dann für immer mitschleppen.
    • Ironischerweise habe ich früher einmal einen Load Balancer in Go geschrieben und mich dabei auf die zufällige Map-Iterationsreihenfolge verlassen.
    • Das ist einer der am meisten unterschätzten Aspekte von Go. Code, den man vor 12 Jahren geschrieben hat, funktioniert immer noch einfach so.
  • Die konkret erwähnte Lösung für das Problem ist, keine stringbasierten Fehler zu verwenden, sondern Sentinel Errors https://thomas-guettler.de/go/wrapping-and-sentinel-errors.
    Allgemeiner gesagt sollte man keinen Code bauen, bei dem API-Konsumenten auch nur in Versuchung kommen, sich auf nichttechnische Strings zu verlassen. Wenn man First-Class-Sprachkonstrukte wie vordefinierte Fehlerwerte, Typen oder Konstanten mit nichttechnischen Strings verwendet, können API-Konsumenten Rückgabewerte mit Konstanten vergleichen, statt Strings direkt hart zu codieren.
    Hyrums Gesetz existiert eindeutig, aber man kann seine Auswirkungen verringern.

    • Das Ärgerliche ist, dass der betreffende Fehler bereits ein Sentinel Error ist.
      Grafana, das in der verlinkten Suche wie die Hauptursache aussieht, hätte statt Stringvergleich errors.As(&http.MaxBytesError{}) verwenden sollen.
      Der Kern von Hyrums Gesetz ist, dass es egal ist, wie gut man eine API entwirft. Menschen verlassen sich auf Verhalten, nicht auf Verträge.
    • In diesem Beispiel liegt die Verantwortung beim Konsumenten, nicht beim Anbieter.
      Man kann immer noch Code schreiben, der err.String() == "no more tea available." prüft. Ich stimme zu, dass man das nicht tun sollte, aber nichts verhindert es.
      Außerdem wurde errors.Is erst relativ spät zu Go hinzugefügt, daher war es zu der Zeit, als Leute Fehler auf diese Weise prüften, einfacher, den Literal-String zu prüfen. In Go kann ein API-Anbieter nicht verhindern, dass Konsumenten den Rückgabewert von .String() prüfen.
    • Vor einigen Jahren war der Vergleich von Fehler-Strings die einzige Möglichkeit, das zu tun, und Go hat ein Abwärtskompatibilitätsversprechen.
    • Code, der rohe Fehler-Strings prüft, ist schlicht schlechter Code und sollte von Gos Abwärtskompatibilitätsgarantie ausgeschlossen werden.
      Besonders in der Standardbibliothek gibt es dafür kaum eine Entschuldigung.
    • Das ursprüngliche Design von Go ist das Problem. Lange Zeit waren stringbasierte Fehler die einzige Möglichkeit, und wenn ich mich richtig erinnere, gibt es sie noch in einigen Standardbibliothek-Paketen, vom gesamten Ökosystem ganz zu schweigen.
      So etwas passiert, wenn man die Geschichte der Programmiersprachen absichtlich ignoriert und den Ansatz „wir entwerfen es beim Bauen“ wählt.
  • Auch der Umgang mit Hyrums Gesetz ist ein interessantes Thema.
    Eine Möglichkeit besteht darin, dort Zufälligkeit einzubauen, wo man nicht möchte, dass sich Leute darauf verlassen.
    Wenn ich mich richtig erinnere, macht das das QUIC-Protokoll so. In der aktuellen Version gibt es ein Feld, das nicht genutzt wird, aber die Spezifikation verlangt, es nicht auf Null-Bytes, sondern auf einen Zufallswert zu setzen, damit Router nicht anfangen können, Pakete anhand dieses Felds zu identifizieren.
    Die Quelle ist vermutlich hier: https://www.rfc-editor.org/rfc/rfc9000#section-17.2.1
    „Der Wert des Unused-Felds wird vom Server auf einen beliebigen Wert gesetzt. Der Client muss den Wert dieses Felds ignorieren. [...] Beachten Sie, dass andere Versionen von QUIC möglicherweise keine ähnliche Empfehlung enthalten.“
    Soweit ich weiß, nennt man so etwas greasing, und es dient dazu, ossification zu verhindern.

    • GREASE ist ein in RFC 8701 geprägtes Akronym und steht für „Generate Random Extensions And Sustain Extensibility“; ursprünglich wurde es im TLS-Kontext verwendet.
      https://www.rfc-editor.org/rfc/rfc8701.html
      Der früheste Entwurf dieses RFC geht auf Mitte 2016 zurück; vermutlich war das der Zeitpunkt, an dem der Begriff erstmals öffentlich auftauchte: https://datatracker.ietf.org/doc/html/draft-davidben-tls-gre...
    • Großartig. Mit QUIC bin ich ziemlich vertraut, aber das wusste ich nicht.
      Es gibt kaum etwas Schlimmeres, als in 10 Jahren aufzuwachen, diese Bits wirklich zu brauchen und dann festzustellen, dass 20 Router-Modelle von 10 Marken entschieden haben, dass diese Bits zwingend auf eine bestimmte Weise gesetzt sein müssen.
      Zusatzpunkte, wenn es auf der anderen Seite eine Prüfsumme oder Verschlüsselung gibt und alles kaputtgeht, sobald die Bits angefasst werden. Die „cleveren Hacks“ von Middleboxes sind wirklich ein Albtraum.
  • Das ist ein gutes Beispiel für stringly typed Software.
    Die Go-Designer wollten keine Exceptions, aber mit panic/recover gibt es immer noch etwas Ähnliches, und untypisierte Fehler sind schädlich. Umgekehrt: Wie soll man typisierte Fehler ohne Pattern Matching behandeln? Das catch der meisten Sprachen ist schließlich eine primitive Form von Pattern Matching.
    https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...

    • Go hat typisierte Fehler. Sie wurden in diesem Fall nur nicht verwendet.
  • In einem früheren Job habe ich einmal einen Tippfehler in einer Fehlermeldung entdeckt und korrigiert, nur um dann festzustellen, dass das Geflecht an Abhängigkeiten von genau diesem falsch geschriebenen Text so tief war, dass er praktisch nicht zu beheben war. Am Ende musste ich wieder zur Version mit Tippfehler zurückkehren.
    Das beschäftigt mich immer noch.

  • Das ist eine Art Hyrums Gesetz, aber eigentlich einfach typisch Go.
    Wenn der Fehler ein enum-Typ gewesen wäre, hätten Konsumenten ihn allein durch String-Ersetzung ändern können. Stattdessen werden Strings wie Typen verwendet, sodass man nicht wissen kann, worauf Konsumenten sich verlassen. Vielleicht prüfen sie nur 6 Zeichen aus der Fehlermeldung, und eine Änderung macht alles kaputt.
    Eine weitere furchtbare, anachronistische Designentscheidung, obwohl es seit Jahrzehnten bessere Alternativen in anderen Sprachen gibt. Wenn frühe Fehler und Unveränderbarkeit zusammenkommen, ist man für immer daran gebunden.

    • Leider ist diese Anmerkung im Kern falsch. In vielen Fällen war der String selbst die offizielle API.
  • Interessant ist, dass dieses Gesetz dem Robustheitsprinzip, also Postels Gesetz, genau entgegengesetzt ist.
    „Sei konservativ beim Senden und großzügig beim Empfangen.“
    Wenn man Eingaben großzügig akzeptiert, muss man verstehen und zumindest intern dokumentieren, auf welche Arten man großzügig war. Wegen Hyrums Gesetz muss man all diese Varianten nach großen Codebase-Änderungen für immer unterstützen.
    Genau aus diesem Grund möchte ich keine APIs bauen, die „großzügig beim Empfangen“ sind.

    • Das bevorzuge ich auch.
      Wenn man die Anforderungen an Daten, die eine API entgegennimmt, locker definiert, muss man am Ende entscheiden, in welches kanonische Format man diese Daten massiert. Und diese Entscheidung führt fast immer auf irgendeine Weise zu Verhalten, das Nutzer überrascht.
  • Je nach Paketautor scheint unterschiedlich stark akzeptiert zu werden, dass dieses Problem existiert. Vor ein paar Tagen habe ich im json-Paket diesen Kommentar gesehen:
    isValidNumber meldet, ob s ein gültiges JSON-Zahlenliteral ist.
    isValidNumber sollte ein internes Implementierungsdetail sein, aber weit verbreitete Pakete greifen per linkname darauf zu.
    Zu den prominenten Mitgliedern der hall of shame gehört github.com/bytedance/sonic.

  • Dinge, die ich beim Veröffentlichen von APIs gelernt habe:
    Clients tun alles, was nötig ist, um ihre Aufgabe zu erledigen, auch wenn es nicht der vom Herausgeber vorgesehene Weg ist. Clients lesen die Dokumentation nicht. Wenn sich genügend Clients auf ein bestimmtes Verhalten verlassen, wird auch ein Bug Teil der API. Die Anzahl der API-Aufrufe entspricht nicht zwingend der Wichtigkeit.
    Deshalb versuche ich bei der Entwicklung von APIs, möglichst früh eine Beta-API herauszugeben und anhand der Nutzung Überraschungen zu reduzieren. In den meisten Fällen erhöhe ich die Hauptversion und unterstütze die alte Version weiter. Dafür muss man das SLA der API definieren.