2 puntos por GN⁺ 2024-11-23 | 1 comentarios | Compartir por WhatsApp
  • En net/http del código base de Go hay un comentario que indica que no se puede cambiar la cadena de error "http: request body too large" devuelta por MaxBytesError.Error() debido a Hyrum's Law
  • Hyrum's Law es el principio según el cual, cuando una API tiene suficientes usuarios, alguien terminará dependiendo incluso de comportamientos observables que no forman parte del contrato oficial
  • Incluso una cadena que parece menor, como un mensaje de error, puede romper código existente en cuanto se modifica si código externo funciona en función del texto exacto
  • Dentro de Go también hay comentarios similares en crypto/rsa e internal/weak, que tratan el riesgo de que se fijen comportamientos de streams aleatorios o semánticas aún no definidas
  • Como no es un problema limitado a Go, las API públicas y las bibliotecas deben diseñarse para evitar que comportamientos no intencionales terminen consolidándose de facto como un estándar

Hyrum's Law observada en código de Go

  • MaxBytesError.Error() en net/http/request.go devuelve la siguiente cadena
    • "http: request body too large"
    • El comentario correspondiente dice: “Due to Hyrum's law, this text cannot be changed.”
  • Hyrum's Law es un principio que lleva el nombre de Hyrum Wright, y la definición de hyrumslaw.com es la siguiente
    • Con una cantidad suficiente de usuarios de una API, no importa qué se haya prometido en el contrato: alguien dependerá de todos los comportamientos observables del sistema
  • Lo central del caso de MaxBytesError es que el texto exacto de un mensaje de error puede ser usado por código externo
    • Incluso un pequeño cambio de redacción puede romper código existente
    • En los resultados de búsqueda de http: request body too large se confirma la existencia de código open source en Go que usa esa cadena

Casos en otros paquetes de Go y en bases de código externas

  • Dependencia del stream aleatorio en crypto/rsa

    • EncryptOAEP en crypto/rsa/rsa.go contiene un comentario relacionado con Hyrum's Law
    • Esta función no promete una ejecución determinista respecto del stream aleatorio, pero como no aplica MaybeReadByte, existe la posibilidad de que alguien dependa del comportamiento actual
    • SignPSS en crypto/rsa/pss.go también incluye un comentario en el mismo contexto
    • En ambos casos, un número bien definido de bytes aleatorios se incluye de una forma bien definida en el texto cifrado o en la firma, por lo que se trata como una promesa tolerable
  • Riesgo de fijación de semántica en internal/weak

    • internal/weak indica que el toolchain prohíbe explícitamente acceder a ese paquete y a sus funciones de referencia mediante go:linkname
    • La semántica de este paquete no pasó por el proceso de propuestas, y si se expone la funcionalidad, Hyrum's Law podría hacer que la semántica existente quede fijada
  • Un patrón que también se repite fuera de Go

    • Las menciones a Hyrum's Law no se limitan a Go
    • En los resultados de búsqueda multilenguaje de grep.app se pueden ver casos en varios lenguajes
    • urllib.parse de Python y array.h de Pixar OpenUSD también son ejemplos de bases de código relacionadas
    • La evolución de JavaScript también se relaciona con casos en los que una dependencia amplia de varios comportamientos extraños y no intencionales terminó convirtiéndose de facto en un estándar

Qué revisar antes de hacer cambios

  • Al cambiar código, hay que considerar no solo las API documentadas, sino también los comportamientos observables de los que podría depender código externo
  • Es necesario diseñar sistemas desde el principio para reducir la posibilidad de dependencia de comportamientos no intencionales

1 comentarios

 
GN⁺ 2024-11-23
Comentarios de Hacker News
  • La ley de Hyrum es una observación útil, pero no hay que obsesionarse con ella y sacar conclusiones equivocadas
    El tiempo total de ejecución de una función también es una propiedad observable, así que incluso optimizar una función para que sea más rápida podría considerarse un cambio rompedor. De pronto, una cola podría vaciarse demasiado rápido y provocar un interbloqueo. Aun así, al 99.99999999% de los usuarios les encantará que su código se vuelva más rápido sin hacer ningún esfuerzo
    Al final, qué cuenta como un cambio rompedor no puede ser más que un contrato social, no un contrato técnico. De otro modo, literalmente no se podría cambiar nada. Quien escribe una biblioteca debe documentar qué partes de la API no van a cambiar, actuar de forma razonable y tener empatía con los usuarios; y quien usa la biblioteca debe entender que convertir una interfaz no documentada en una dependencia crítica es su propia responsabilidad, y también tener empatía con quien la mantiene

    • Creo que todo eso aplica totalmente a quienes mantienen bibliotecas de código abierto
      Pero desde otra perspectiva, la ley de Hyrum no es ni un contrato técnico ni un contrato social, sino una propiedad técnica emergente que aparece en sistemas con suficiente uso
      Cómo responder a esa propiedad depende del contexto social. Si eres mantenedor de FOSS, publicas una optimización si hace que el 99.99% vaya más rápido y solo el 0.01% necesita ajustar su código o migrar a una API nueva. Si estás en una gran empresa tecnológica, también debes optimizar, pero como dentro de la empresa no puede romperse ni el 0%, colaboras con varios equipos para encontrar un punto de equilibrio. Si eres una empresa de software empresarial, no lo publicas si aunque solo se rompa el 0.1%, ese usuario resulta ser uno de tus cinco contratos más importantes
    • Una vez reduje una rutina muy ineficiente de alrededor de 100 segundos a 0.1 segundos, y eso rompió el sistema de reportes
      El autor original había llamado varias funciones asíncronas y asumía que para cuando terminara la rutina lenta de antes, todas esas funciones ya habrían terminado. Me tomó muchísimo tiempo averiguar exactamente qué estaba pasando
    • En los años 80 de verdad existían problemas así
      Por eso las PC traían un botón turbo para bajar la velocidad, y las computadoras de 8 bits no aumentaron su velocidad durante una década entera, aunque ya existían CPU más rápidas. Hoy casi todo corre en dos o más CPU, así que salvo por el caso de que algo sea lo suficientemente rápido, casi no hay dependencia del tiempo de ejecución de una función. Incluso en sistemas embebidos se intenta evitar esa clase de dependencia después de haber pasado por la descontinuación de una CPU única
    • Algún día me gustaría dar una lightning talk sobre el load bearing teapot
      Sería sobre por qué convertimos el HTTP Status 418 en una dependencia crítica de una API interna y por qué, dadas las restricciones que teníamos, esa era la opción menos mala
    • Algo como el tiempo total de ejecución de una función no está bajo el control de quien la escribió, así que esta lógica parece casi absurda
      El entorno de ejecución, la carga del sistema en ese momento, la ejecución del GC y muchas otras cosas pueden influir
      En resumen, no veo el comportamiento emergente de una máquina como una interfaz intencional ni como ningún tipo de contrato. Por lo tanto, aunque alguien dependa de un comportamiento no intencional, no considero que corregir un bug sutil sea un cambio rompedor, y esto tampoco lo sería
      En este caso, más que nada, me parece una muestra de lo fuertemente comprometido que está Go con la compatibilidad hacia atrás
  • Ja, ja, yo escribí el comentario de crypto/rsa. En Go se toman muy en serio la ley de Hyrum y la compatibilidad hacia atrás https://go.dev/doc/go1compat
    Por ejemplo, en varias funciones GenerateKey, para que el algoritmo no quede fijado, se lee un byte adicional del flujo aleatorio con MaybeReadByte https://pkg.go.dev/crypto/internal/randutil#MaybeReadByte. Justo ayer también llegó un reporte de que una private ECDSA key con una public key nil antes funcionaba y ahora ya no, así que probablemente haya que hacer que vuelva a funcionar https://go.dev/issue/70468
    La iteración de mapas usa un orden aleatorio para no exponer la implementación interna. La salida de rand.Rand se considera parte de la promesa de compatibilidad, así que hubo que hacer un esfuerzo bastante grande para poder mejorarla https://go.dev/blog/randv2 https://go.dev/blog/chacha8rand
    Siempre se discute qué promesas escribir en la documentación y qué comportamientos marcar explícitamente como “pueden cambiar”. Porque sabemos que lo documentado no se puede cambiar jamás, y que incluso lo que no dice “puede cambiar” probablemente también sea difícil de cambiar https://go-review.googlesource.com/c/go/+/598336/comment/5d6...

    • Cambiar el orden de iteración de mapas ayuda a reducir cambios incompatibles a futuro al evitar que se dependa de un orden específico, pero en el momento del cambio sí fue un cambio incompatible para el código que dependía del comportamiento anterior
      Aun así, me parece un compromiso que vale la pena. Uso mucho Go y me gusta su fuerte compatibilidad hacia atrás, pero si eso les da más libertad a los desarrolladores de Go para mejorar el rendimiento y agregar funciones, acepto con gusto una tasa un poco más alta de cambios incompatibles
      Viendo el infierno que soportan usuarios de otros ecosistemas, por ejemplo Python, no creo que esta idea sea solo mía
    • Dijeron que MaybeReadByte se usa en varias funciones GenerateKey, pero parece que no en ed25519
      Antes de que existiera ed25519.NewKeyFromSeed(), esa era prácticamente la única forma de derivar una public Ed25519 key desde una private key, y es casi seguro que alguna vez escribí código que dependía de eso. No me gustaba mucho, pero era lo único que se podía hacer, así que era fácil de recordar
      Aun así, está bien que la documentación de ed25519.GenerateKey deje claro que la salida es determinista. Creo que han hecho un muy buen trabajo investigando y manteniendo comportamientos ya consolidados en la API criptográfica de Go, y evitando que se consoliden otros nuevos
    • El caso de la nil key hace preguntarse qué tan sensato es soportar incluso casos así
      Como la infame línea A20 (https://en.wikipedia.org/wiki/A20_line), terminas arrastrando este comportamiento roto para siempre
    • Irónicamente, hace tiempo escribí un balanceador de carga en Go que dependía del orden aleatorio de iteración de mapas
    • Es una de las partes más infravaloradas de Go. El código que escribí hace 12 años todavía simplemente funciona
  • La solución al problema mencionado específicamente es no usar errores basados en cadenas, sino errores centinela https://thomas-guettler.de/go/wrapping-and-sentinel-errors
    Más en general, no se debería escribir código que lleve a los consumidores de una API a querer depender, aunque sea un poco, de cadenas no técnicas. Si usas construcciones de primera clase del lenguaje, como valores de error predefinidos, tipos o constantes que contengan cadenas no técnicas, los consumidores de la API pueden comparar el valor de retorno con una constante en vez de hardcodear directamente la cadena
    La ley de Hyrum claramente existe, pero se puede reducir su impacto

    • Lo frustrante es que el error del problema ya es un error centinela
      Grafana, que parece ser la causa principal en la búsqueda enlazada, debería haber usado errors.As(&http.MaxBytesError{}) en vez de comparar cadenas
      El punto central de la ley de Hyrum es que no importa qué tan bien diseñes una API. La gente termina dependiendo del comportamiento, no del contrato
    • En este ejemplo, la responsabilidad es del consumidor, no del proveedor
      Igual todavía puedes escribir código que revise err.String() == "no more tea available.". Estamos de acuerdo en que no deberías hacerlo, pero no hay nada que lo impida
      Además, errors.Is se agregó a Go relativamente hace poco, así que en la época en que la gente revisaba errores de esta forma, verificar cadenas literales era más fácil. En Go, un proveedor de API no puede impedir que el consumidor revise el valor de retorno de .String()
    • Hace algunos años, la comparación de errores por cadena era la única forma de hacer esto, y Go tiene una promesa de compatibilidad hacia atrás
    • El código que revisa cadenas de error sin procesar es simplemente mal código, y debería quedar fuera de la garantía de compatibilidad hacia atrás de Go
      Especialmente en la biblioteca estándar, casi no hay excusa posible
    • El problema está en el diseño inicial de Go. Durante mucho tiempo, los errores basados en cadenas fueron la única forma, y si no recuerdo mal todavía quedan en algunos paquetes de la biblioteca estándar, por no hablar de todo el ecosistema
      Esto es lo que pasa cuando ignoras deliberadamente la historia de los lenguajes de programación y adoptas el enfoque de “diseñemos sobre la marcha”
  • También es interesante el tema de cómo resistir la ley de Hyrum
    Una posibilidad es introducir aleatoriedad en las partes de las que no quieres que la gente dependa
    Si no recuerdo mal, el protocolo QUIC hace esto. Hay campos no usados en la versión actual, pero la especificación exige que se establezcan con valores aleatorios, no con bytes nulos, para evitar que los routers empiecen a identificar paquetes por esos campos
    La fuente probablemente es esta: https://www.rfc-editor.org/rfc/rfc9000#section-17.2.1
    “El valor del campo Unused lo establece el servidor con un valor arbitrario. El cliente debe ignorar obligatoriamente el valor de este campo. [...] Ten en cuenta que otras versiones de QUIC podrían no hacer una recomendación similar”
    Tengo entendido que a esto se le llama greasing, y que sirve para evitar la ossification

    • GREASE es un acrónimo acuñado en el RFC 8701, que significa “Generate Random Extensions And Sustain Extensibility”, y al principio se usó en el contexto de TLS
      https://www.rfc-editor.org/rfc/rfc8701.html
      El borrador más antiguo de este RFC se remonta a mediados de 2016, y probablemente ese sea el primer momento en que el término apareció públicamente: https://datatracker.ietf.org/doc/html/draft-davidben-tls-gre...
    • Excelente. Estoy bastante familiarizado con QUIC, pero no sabía esto
      Hay pocas cosas tan horribles como despertarte dentro de 10 años y descubrir que de verdad necesitabas esos bits, pero 20 modelos de router de 10 marcas decidieron que esos bits tenían que ser necesariamente de cierta forma
      Si del otro lado hay checksum o cifrado y se rompe cuando alguien toca esos bits, mejor todavía. Los “hacks inteligentes” de las middleboxes de verdad son un dolor de cabeza
  • Este es un buen ejemplo de software stringly typed
    Los diseñadores de Go no querían excepciones, pero con panic/recover igual tienen algo parecido, y los errores sin tipo son dañinos. Por otro lado, ¿cómo manejar errores con tipo sin pattern matching? Después de todo, el catch de la mayoría de los lenguajes es una forma rudimentaria de pattern matching
    https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...

    • En Go sí hay errores con tipo. Solo que en este caso no se usaron
  • En un trabajo anterior detecté un error tipográfico en un mensaje de error y lo corregí, solo para descubrir que la red de dependencias sobre ese texto con error era tan profunda que en la práctica no se podía arreglar, y al final hubo que volver a poner el texto con el error
    Todavía me molesta

  • Esto es una especie de ley de Hyrum, pero en la práctica es simplemente Go siendo Go
    Si el error hubiera sido un tipo enum, el consumidor habría podido cambiarlo con una simple sustitución de cadena. En cambio, como están usando cadenas como si fueran tipos, ya no se puede saber de qué forma depende el consumidor. Tal vez alguien solo verifica 6 letras en medio del mensaje de error y se rompe si eso cambia
    Hace décadas que se usan alternativas mejores en otros lenguajes, y esta es otra decisión de diseño horrible y anacrónica. Si combinas errores iniciales con la imposibilidad de cambiarlos, quedas atado para siempre

    • Lamentablemente, ese comentario es esencialmente incorrecto. En muchos casos, la cadena misma era la API oficial
  • Es interesante que esta ley sea exactamente lo opuesto del principio de robustez, es decir, la ley de Postel
    “Sé conservador en lo que envías y liberal en lo que aceptas”
    Si aceptas entradas de forma liberal, tienes que entender de qué maneras estás siendo liberal y al menos documentarlo internamente. Por la ley de Hyrum, después de un cambio grande en la base de código, tendrás que seguir soportando todas esas formas para siempre
    Precisamente por eso trato de no crear APIs que sean “liberales en lo que aceptan”

    • Yo también prefiero ese enfoque
      Si relajas los criterios de los datos que aceptas por una API, al final tienes que decidir cómo transformar esos datos hacia algún formato canónico. Y esa decisión casi siempre termina causando un comportamiento sorprendente para el usuario de una forma u otra
  • Parece que cada autor de paquetes acepta este problema en distinto grado. Hace unos días vi este comentario en el paquete json
    isValidNumber informa si s es un literal numérico JSON válido
    isValidNumber debería ser un detalle interno de implementación, pero paquetes muy usados acceden a él mediante linkname
    Entre los integrantes destacados del hall of shame está github.com/bytedance/sonic

  • Son cosas que aprendí publicando APIs
    Los clientes hacen lo que sea necesario para terminar su trabajo, aunque no sea la forma en que el publicador lo pretendía. Los clientes no leen la documentación. Si suficientes clientes dependen de cierto comportamiento, hasta un bug pasa a ser parte de la API. La cantidad de llamadas a una API no necesariamente coincide con su importancia
    Por eso, al desarrollar una API, trato de sacar una beta lo antes posible y ver cómo la usan para reducir sorpresas. En la mayoría de los casos aumento la versión principal manteniendo soporte para la versión anterior. Para eso hay que definir el SLA de la API