2 pontos por GN⁺ 2024-11-23 | 1 comentários | Compartilhar no WhatsApp
  • Na base de código Go, em net/http, há um comentário dizendo que a string de erro "http: request body too large", retornada por MaxBytesError.Error(), não pode ser alterada por causa da Hyrum's Law
  • Hyrum's Law é o princípio de que, quando uma API tem usuários suficientes, alguém passa a depender até de comportamentos observáveis que não fazem parte do contrato oficial
  • Mesmo strings que parecem triviais, como mensagens de erro, podem quebrar código existente no momento em que forem alteradas, se código externo se comportar com base no texto exato
  • Dentro do Go, também há comentários semelhantes em crypto/rsa e internal/weak, tratando do risco de comportamentos de fluxo aleatório ou semânticas ainda não definidas ficarem congelados
  • Como o problema não se limita ao Go, APIs públicas e bibliotecas devem ser projetadas para evitar que comportamentos não intencionais acabem se cristalizando como um padrão de fato

Hyrum's Law identificada no código Go

  • MaxBytesError.Error(), em net/http/request.go, retorna a seguinte string
    • "http: request body too large"
    • O comentário correspondente diz: “Due to Hyrum's law, this text cannot be changed.”
  • Hyrum's Law é um princípio batizado em referência a Hyrum Wright, e a definição em hyrumslaw.com é a seguinte
    • Com um número suficiente de usuários de uma API, alguém passará a depender de todos os comportamentos observáveis do sistema, independentemente do que foi prometido no contrato
  • O ponto central do caso de MaxBytesError é que o texto exato de uma mensagem de erro pode ser usado por código externo
    • Mesmo uma pequena alteração no texto pode quebrar código existente
    • Os resultados da busca por http: request body too large mostram código open source em Go que usa essa string

Casos em outros pacotes Go e bases de código externas

  • Dependência de fluxo aleatório em crypto/rsa

    • EncryptOAEP, em crypto/rsa/rsa.go, tem um comentário relacionado à Hyrum's Law
    • Essa função não promete execução determinística em relação ao fluxo aleatório, mas, por não aplicar MaybeReadByte, existe a possibilidade de alguém depender do comportamento atual
    • SignPSS, em crypto/rsa/pss.go, também inclui um comentário no mesmo contexto
    • Em ambos os casos, um número bem definido de bytes aleatórios é incluído de uma maneira bem definida no texto cifrado ou na assinatura, então isso é tratado como uma promessa tolerável
  • Risco de congelamento da semântica em internal/weak

    • internal/weak afirma que a toolchain proíbe explicitamente o acesso a esse pacote e às funções de referência por meio de go:linkname
    • A semântica desse pacote não passou pelo processo de proposta, e expor a funcionalidade poderia congelar a semântica existente por causa da Hyrum's Law
  • Um padrão que se repete também fora do Go

    • Menções à Hyrum's Law não se limitam ao Go
    • Nos resultados de busca multilíngue do grep.app, é possível ver casos em várias linguagens
    • urllib.parse, do Python, e array.h, do Pixar OpenUSD, também são exemplos de bases de código relacionadas
    • A evolução do JavaScript também se conecta a casos em que dependências amplas de vários comportamentos estranhos e não intencionais acabaram se tornando padrões de fato

O que verificar antes de fazer uma alteração

  • Ao alterar código, é preciso considerar não só APIs documentadas, mas também comportamentos observáveis dos quais código externo possa depender
  • É necessário projetar sistemas desde o início de modo a reduzir a possibilidade de dependência de comportamentos não intencionais

1 comentários

 
GN⁺ 2024-11-23
Opiniões no Hacker News
  • A Lei de Hyrum é uma observação útil, mas não se deve ficar obcecado por ela e chegar a conclusões equivocadas
    O tempo total de execução de uma função também é uma propriedade observável; portanto, até otimizar uma função para torná-la mais rápida poderia ser visto como uma alteração incompatível. Afinal, a fila poderia de repente esvaziar rápido demais e causar um deadlock. Mesmo assim, 99,99999999% dos usuários provavelmente ficariam felizes em ver o código ficar mais rápido sem nenhum esforço
    No fim, o que é uma alteração incompatível só pode ser um contrato social, não um contrato técnico. Caso contrário, literalmente nada poderia ser mudado. Autores de bibliotecas devem documentar as partes da API que não vão mudar, agir de forma razoável e ter empatia pelos usuários; usuários de bibliotecas devem entender que depender de interfaces não documentadas como dependências essenciais é por sua conta e risco, e também ter empatia pelos autores

    • Para autores de bibliotecas open source, acho que tudo isso está certo
      Mas, por outro ponto de vista, a Lei de Hyrum não é nem um contrato técnico nem um contrato social, e sim uma propriedade técnica emergente que aparece em sistemas usados em escala suficiente
      Como responder a essa propriedade depende do contexto social. Se você é mantenedor de FOSS, publica a otimização quando 99,99% ficam mais rápidos e só 0,01% precisam corrigir o código ou migrar para uma nova API. Se você está em uma grande empresa de tecnologia, precisa otimizar e, ao mesmo tempo, nada dentro da empresa pode quebrar, nem 0%; então colabora com várias equipes para encontrar um meio-termo. Se você é uma empresa de software corporativo, não publica se até 0,1% quebrar, caso esse usuário esteja entre os 5 maiores contratos
    • Certa vez, reduzi uma rotina muito ineficiente de cerca de 100 segundos para 0,1 segundo, e isso quebrou o sistema de relatórios
      O autor original chamava várias funções assíncronas e presumia que, quando a rotina antiga e lenta terminasse, todas elas já teriam terminado. Demorou muito tempo para descobrir exatamente o que estava acontecendo
    • Nos anos 1980, esse tipo de problema realmente existia
      Por isso os PCs tinham um botão turbo que reduzia a velocidade, e computadores de 8 bits não aumentaram a velocidade durante uma década inteira, apesar de terem CPUs mais rápidas disponíveis. Hoje, quase tudo roda em duas ou mais CPUs; por isso, salvo a exigência de ser rápido o bastante, quase ninguém depende do tempo de execução de uma função. Mesmo em embarcados, depois de passar pela descontinuação de CPUs específicas, as pessoas tentam evitar esse tipo de dependência
    • Um dia quero fazer uma lightning talk sobre um load bearing teapot
      Seria uma história sobre por que transformamos o HTTP Status 418 em uma dependência essencial em uma API interna e por que, dadas as restrições, essa era a escolha menos ruim
    • Algo como o tempo total de execução de uma função não está sob controle do autor da função, então essa lógica parece quase absurda
      O ambiente de execução, a carga do sistema naquele momento, a execução do GC etc. podem todos influenciar
      Em resumo, não vejo comportamento emergente gerado pela máquina como uma interface intencional ou qualquer tipo de contrato. Portanto, mesmo que alguém tenha dependido de um comportamento não intencional, assim como corrigir um bug sutil não é considerado uma alteração incompatível, eu também não consideraria isso uma alteração incompatível
      Neste caso, acima de tudo, parece mais uma prova do fortíssimo compromisso do Go com compatibilidade retroativa
  • Haha, fui eu que escrevi o comentário em crypto/rsa. Em Go, levamos a Lei de Hyrum e a compatibilidade retroativa https://go.dev/doc/go1compat muito a sério
    Por exemplo, várias funções GenerateKey leem um byte extra do fluxo aleatório com MaybeReadByte https://pkg.go.dev/crypto/internal/randutil#MaybeReadByte para que o algoritmo não fique fixo. Ainda ontem chegou um relato de que uma chave privada ECDSA com chave pública nil funcionava antes e agora não funciona, então provavelmente teremos que fazê-la voltar a funcionar https://go.dev/issue/70468
    A iteração de mapas usa uma ordem aleatória para que a implementação interna não fique exposta. A saída de rand.Rand é considerada parte da promessa de compatibilidade, então foi preciso um esforço bem grande para melhorá-la https://go.dev/blog/randv2 https://go.dev/blog/chacha8rand
    Estamos sempre discutindo quais promessas colocar na documentação e quais comportamentos declarar como “podem mudar”. Isso porque sabemos que o que está documentado nunca pode ser alterado, e mesmo o que não foi explicitamente marcado como “pode mudar” provavelmente será difícil de mudar https://go-review.googlesource.com/c/go/+/598336/comment/5d6...

    • A mudança na ordem de iteração de mapas ajuda a reduzir mudanças incompatíveis futuras ao impedir dependência de uma ordem específica, mas, no momento da mudança, foi uma alteração incompatível para código que dependia do comportamento de ordem anterior
      Ainda assim, acho que foi uma troca valiosa. Uso bastante Go e gosto da forte compatibilidade retroativa, mas aceitaria de bom grado uma taxa um pouco maior de mudanças incompatíveis se isso desse aos desenvolvedores de Go mais liberdade para melhorar desempenho e adicionar recursos
      Vendo o inferno que usuários de outros ecossistemas enfrentam, como Python, por exemplo, acho que não sou o único a pensar assim
    • Foi dito que MaybeReadByte é usado em várias funções GenerateKey, mas parece que isso não acontece em ed25519
      Antes de existir ed25519.NewKeyFromSeed(), essa era a única forma de derivar uma chave pública Ed25519 a partir de uma chave privada, e tenho quase certeza de que escrevi código que dependia disso. Eu não gostava muito, mas era a única coisa possível, então é fácil de lembrar
      Dito isso, é bom que a documentação de ed25519.GenerateKey declare que a saída é determinística. Acho que a API de criptografia de Go tem feito um trabalho realmente bom em investigar e manter comportamentos que se cristalizaram, além de evitar novas cristalizações
    • O caso da chave nil faz questionar até que ponto faz sentido dar suporte a esse tipo de situação
      Como a infame linha A20 (https://en.wikipedia.org/wiki/A20_line), acaba sendo necessário carregar esse comportamento quebrado para sempre
    • Ironicamente, certa vez escrevi um balanceador de carga em Go que dependia da ordem aleatória de iteração de mapas
    • É uma das partes mais subestimadas de Go. Código que escrevi 12 anos atrás ainda simplesmente funciona
  • A solução para o problema mencionado especificamente é não usar erros baseados em strings, e sim erros sentinela https://thomas-guettler.de/go/wrapping-and-sentinel-errors
    De forma mais geral, não se deve criar código que faça o consumidor da API sequer ter vontade de depender de strings não técnicas. Se forem usados elementos de primeira classe da linguagem, como valores de erro predefinidos, tipos ou constantes contendo strings não técnicas, o consumidor da API pode comparar o valor retornado com a constante em vez de hardcodar a string diretamente
    A Lei de Hyrum claramente existe, mas é possível reduzir seu impacto

    • O irritante é que o erro em questão já é um erro sentinela
      A Grafana, que parece ser a causa principal na busca vinculada, deveria ter usado errors.As(&http.MaxBytesError{}) em vez de comparação de string
      O ponto central da Lei de Hyrum é que não importa o quão bem você projete a API. As pessoas passam a depender do comportamento, não do contrato
    • Neste exemplo, a responsabilidade é do consumidor, não do provedor
      Ainda é possível escrever código que verifica err.String() == "no more tea available.". Concordo que não se deve fazer isso, mas não há nada que impeça
      Além disso, errors.Is foi adicionado ao Go relativamente recentemente, então, na época em que as pessoas verificavam erros desse jeito, checar a string literal era mais fácil. Em Go, o provedor da API não consegue impedir o consumidor de verificar o valor retornado por .String()
    • Alguns anos atrás, comparar strings de erro era a única maneira de fazer isso, e Go tem uma promessa de compatibilidade retroativa
    • Código que verifica strings de erro brutas é simplesmente código ruim e deveria ficar fora da garantia de compatibilidade retroativa de Go
      Especialmente na biblioteca padrão, quase não há desculpa
    • O problema está no design inicial de Go. Por muito tempo, erros baseados em strings eram a única maneira e, se não me falha a memória, ainda existem em alguns pacotes da biblioteca padrão, sem falar no ecossistema como um todo
      É isso que acontece quando se ignora deliberadamente a história das linguagens de programação e se adota uma abordagem de “vamos projetar enquanto construímos”
  • As formas de enfrentar a Lei de Hyrum também são um tema interessante
    Uma possibilidade é inserir aleatoriedade nas partes das quais se espera que as pessoas não dependam
    Se minha memória não falha, o protocolo QUIC faz isso. Na versão atual há um campo que não é usado, mas, para impedir que roteadores comecem a identificar pacotes por esse campo, a especificação exige que ele seja definido com um valor aleatório, não com um byte nulo
    A fonte provavelmente é esta: https://www.rfc-editor.org/rfc/rfc9000#section-17.2.1
    “O valor do campo Unused é definido pelo servidor como um valor arbitrário. O cliente deve ignorar esse valor de campo. [...] Observe que outras versões do QUIC podem não fazer recomendação semelhante”
    Pelo que sei, isso é chamado de greasing e serve para evitar ossification

    • GREASE é um acrônimo criado na RFC 8701, significa “Generate Random Extensions And Sustain Extensibility” e foi usado inicialmente no contexto de TLS
      https://www.rfc-editor.org/rfc/rfc8701.html
      O rascunho mais antigo dessa RFC remonta a meados de 2016, e é bem possível que tenha sido a primeira aparição pública do termo: https://datatracker.ietf.org/doc/html/draft-davidben-tls-gre...
    • Excelente. Estou bastante familiarizado com QUIC, mas não sabia disso
      Nada seria tão horrível quanto acordar daqui a 10 anos, descobrir que esses bits realmente passaram a ser necessários, e então ver que 20 modelos de roteadores de 10 marcas decidiram que esses bits precisam estar de uma forma específica
      Pontos extras se houver checksum ou criptografia do outro lado, fazendo tudo quebrar caso os bits sejam alterados. Os “hacks espertos” das middleboxes são uma verdadeira dor de cabeça
  • Este é um bom exemplo de software stringly typed
    Os projetistas de Go não queriam exceções, mas com panic/recover ainda há algo parecido, e erros sem tipo são prejudiciais. Por outro lado, como tratar erros tipados sem pattern matching? Afinal, o catch da maioria das linguagens é uma forma rudimentar de pattern matching
    https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...

    • Go tem erros tipados. Eles simplesmente não foram usados neste caso
  • Em um emprego antigo, encontrei um erro de digitação em uma mensagem de erro e o corrigi, só para descobrir que a rede de dependências daquele texto com erro era tão profunda que, na prática, não dava para consertar; no fim, tivemos de voltar ao texto com erro
    Isso ainda me incomoda

  • Isto é uma espécie de Lei de Hyrum, mas na prática é apenas Go sendo Go
    Se o erro fosse um tipo enum, o consumidor poderia mudá-lo apenas com substituição de strings. Em vez disso, como strings estão sendo usadas como se fossem tipos, torna-se impossível saber do que o consumidor pode estar dependendo. Ele pode estar verificando apenas 6 caracteres no meio da string de erro, e uma alteração poderia quebrar tudo
    É mais uma decisão de design terrível e anacrônica, apesar de alternativas melhores já existirem em outras linguagens há décadas. Quando erros iniciais se combinam com impossibilidade de mudança, você fica preso a eles para sempre

    • Infelizmente, esse comentário está essencialmente errado. Em muitos casos, a própria string era a API oficial
  • É interessante que esta lei seja exatamente o oposto do princípio da robustez, ou Lei de Postel
    “Seja conservador ao enviar, seja liberal ao receber”
    Se você recebe entradas de forma liberal, precisa entender de que maneiras foi liberal e, no mínimo, documentá-las internamente. Por causa da Lei de Hyrum, mesmo depois de grandes mudanças na base de código, você acaba tendo de dar suporte a todas essas formas para sempre
    É justamente por isso que tento não criar APIs “liberais no que recebem”

    • Também prefiro esse lado
      Se você define critérios frouxos para os dados recebidos pela API, no fim precisa decidir como vai massagear esses dados para alguma forma canônica. E essa decisão quase sempre parece resultar, de algum modo, em um comportamento surpreendente para o usuário
  • Parece que cada autor de pacote aceita esse problema em graus diferentes. Alguns dias atrás, vi este comentário no pacote json
    isValidNumber informa se s é um literal numérico JSON válido
    isValidNumber deveria ser um detalhe interno de implementação, mas pacotes amplamente usados o acessam via linkname
    Entre os principais integrantes do hall of shame está github.com/bytedance/sonic

  • Coisas que aprendi ao publicar APIs
    Clientes fazem tudo o que for necessário para concluir seu trabalho, mesmo que não seja da forma pretendida pelo publicador. Clientes não leem a documentação. Quando um número suficiente de clientes passa a depender de um comportamento, até um bug vira parte da API. A quantidade de chamadas de API não corresponde necessariamente à importância
    Por isso, ao desenvolver APIs, tento lançar APIs beta o mais cedo possível e observar como elas são usadas, para reduzir surpresas. Na maioria dos casos, faço incremento da versão principal mantendo suporte à versão anterior. Para isso, é preciso definir o SLA da API