Windows 95 changed that, and so one of the compatibility shims it got is that the allocator had a 3.x adjacent mode, which would be turned on when running SimCity (and probably other similarly misbehaving software as well).
Nowadays this is formalised in the compatibility engine (dating back to windows do), which can enable special modes or compatibility shims for applications (windows admins trying to run legacy or unmaintained applications can manage the application of compatibility modes via the “compatibility administrator”).
It was a very different world back then. You couldn't even assume a dial-up connection.
Nowadays, the software would have been automatically updated for 99% of the machines running it, whether they wanted that update or not.
The proof is this totally OT comment that doesn't even make any sense :)
Jon Ross, who wrote the original version of SimCity for Windows 3.x, told me that he accidentally left a bug in SimCity where he read memory that he had just freed. Yep. It worked fine on Windows 3.x, because the memory never went anywhere. Here’s the amazing part: On beta versions of Windows 95, SimCity wasn’t working in testing. Microsoft tracked down the bug and added specific code to Windows 95 that looks for SimCity. If it finds SimCity running, it runs the memory allocator in a special mode that doesn’t free memory right away. That’s the kind of obsession with backward compatibility that made people willing to upgrade to Windows 95.
One of the SRE practices is breaking your service on purpose to bring the actual service level closer to what is promised and supported.
More generally: Don't produce code where consumers of your API are the least bit inclined to rely on non-technical strings. Instead use first-level language constructs like predefined error values, types or even constants that contain the non-technical string so that API consumers can compare the return value againnst the constant instead of hard-coding the contained string themselves.
Hyrum's Law is definitely a thing, but its effects can be mitigated.
[1]: https://thomas-guettler.de/go/wrapping-and-sentinel-errors
Then the feature didn't exist. Figuring out undocumented implementation details to "make it work" is asking for it to be broken in the future. So if you are unwilling or unable to support fixing it in the future then don't do that.
If it is "the most basic expected stuff" then quite literally make the determination that it isn't ready for use. A lot of Go was and maybe still be half baked and not ready for production. It is ok to recognize that and not use it.
Unfortunately, many people can't really do that: when the ecosystem turns out to be somewhat inadequate in a project that's already been in use for couple of years, their options are either "just make it work one way or another, who cares if it's a hardcoded string, we have to ship the fix ASAP" or "rewrite it all in Rust/X, allegedly their ecosystem is production-ready".
Sure, but now that there's a "correct" way to do this, you don't get to complain that the hacky thing you did needs to keep being supported. You fix the hacky thing you did, or you make peace that you're still doing the hacky thing, problems it causes and all.
Being serious about compatibility allows the concept of a piece of software being finished. If I finished writing a book twelve years ago, you could still read it today. But if I finished writing a piece of software twelve years ago, could you still build and run it today? Without having to fix anything? Without having to fix lots of things?
> Sure, but now that there's a "correct" way to do this, you don't get to complain that the hacky thing you did needs to keep being supported.
But that's the whole point and beauty of Go's compatibility promise. Once you finish getting something working, you finished getting it working. It works.
What I don't want, is for my programming platform to suddenly say that the way I got the thing working is no longer supported. I am no longer finished getting it working. I will never be finished getting it working.
Go is proving that a world with permanently working software is possible (vs a world with software that breaks over time).
Is it that terrible to just handle an error as an error, without having to know exactly what the error was? If you see some of the codebases which rely on the error, they are trying to be too clever and doing things like returning a 400 instead of 500 if that's the specific error message returned. Is that really necessary?
Unless the codebase can take corrective actions (and it could still attempt to do it regardless if that's the case), there's really no point trying to be cute. An error is returned, and that's that.
The whole point of Hyrum's Law is that it doesn't matter how well you design your API: no matter what, people will depend on its behavior rather than its contract.
What you could do was try to rely on the same undocumented behavior as everyone else. This way, if Apple broke you, they'd break half their ecosystem at the same time.
That is what happens when history of programming languages is ignored on purpose, followed by a "design as we go" approach.
https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...
In other statically typed languages, you can do things like 'match err' and have the compiler tell you if you handled all the variants. In java you can `try { x } catch (SomeTypedException)` and have the compiler tell you if you missed any checked exceptions.
In go, you have to read the recursive call stack of the entire function you called to know if a certain error type is returned.
Can 'pgx.Connect' return an `io.EOF` error? Can it return a "tls: unknown certificate authority" (unexported string only error)?
The only way to know is to recursively read every line of code `pgx.Connect` calls and take note of every returned error.
In other languages, it's part of the type-signature.
Go doesn't have _useful_ typed errors since idiomatically they're type-erased into 'error' the second they're returned up from any method.
Should an unexpected error propagate from deep down in your call stack to your current call site, do you really think that error should be handled at this specific call-site?
https://docs.python.org/3/library/exceptions.html#concrete-e...
and standard about exception type hierarchy
https://github.com/psycopg/psycopg/blob/d38cf7798b0c602ff43d...
https://peps.python.org/pep-0249/#exceptions
Also in most languages "catch Exception:" (or similar expression) is considered a bad style. People are taught to catch specific exceptions. Nothing like that happens in Go.
In python, well, python's a dynamically typed language so of course it doesn't have statically typed exceptions.
"a better type system than C" is a really low bar.
Go should be held to a higher bar than that.
Returning non-specific exceptions is virtually encouraged by the standard library (if you return an error struct, you run into major issues with the ubiquitous `if err != nil` "error handling" logic). You have both errors.New() and fmt.Errorf() for returning stringly-typed errors. errors.Is and errors.As only work easily if you return error constants, not error types (they can support error types, but then you have to do more work to manually implement Is() and As() in your custom error type) - so you can't easily both have a specific error, but also include extra information with that error.
For the example in the OP, you have to do a lot of extra work to return an error that can be checked without string comparisons, but also tells you what was the actual limit. So much work that this was only introduced in Go 1.19, despite MaxBytesReader existing since go 1.0 . Before that, it simply returned errors.New("http: request body too large") [0].
And this is true throughout the standard library. Despite all of their talk about the importance of handling errors, Go's standard library was full of stringly-typed errors for most of its lifetime, and while it's getting better, it's still a common occurrence. And even when they were at least using sentinel errors, they rarely included any kind of machine-readable context you could use for taking a decision based on the error value.
[0] https://cs.opensource.google/go/go/+/refs/tags/go1:src/pkg/n...
package example
var ErrValue = errors.New("stringly")
type ErrType struct {
Code int
Message string
}
func (e ErrType) Error() string {
return fmt.Sprintf("%s (%d)", e.Message, e.Code)
}
You can now use errors.Is with a target of ErrValue and errors.As with a target of *ErrType. No extra methods are needed.However, you can't compare ErrValue to another errors.New("stringly") by design (under the hood, errors.New returns a pointer, and errors.Is uses simple equality). If you want pure value semantics, use your own type instead.
There are Is and As interfaces that you can implement, but you rarely need to implement them. You can use the type system (subtyping, value vs. pointer method receivers) to control comparability in most cases instead. The only time to break out custom implementations of Is or As is when you want semantic equality to differ from ==, such as making two ErrType values match if just their Code fields match.
The one special case that the average developer should be aware of is unwrapping the cause of custom errors. If you do your own error wrapping (which is itself rarely necessary, thanks to the %w specifier on fmt.Errorf), then you need to provide an Unwrap method (returning either an error or a slice of errors).
errors.As does work as you describe, but errors.Is doesn't: that only compares the error argument for equality, unless it implements Is() itself to do something different. So `var e error ErrType{Code: 1, Message: "Good"} = errors.Is(e, ErrType{})` will return false. But indeed Errors.As will work for this case and allow you to check if an error is an instance of ErrType.
In this case the Error has an easy-to-check public type (*MaxBytesError) and the documentation clearly indicates that. But that has not always been the case. The original sin is that the API returned a generic error and the only way to test that error was to use a string comparison.
This is an important context to have when you need to make balanced decisions about Hyrum's law. As some commentators already mentioned, you should be wary of taking the extreme version of the law, which suggest that every single observable behavior of the API becomes part of the API itself and needs to be preserved. If you follow this extreme version, every error or exception message in every language must be left be left unchanged forever. But most client code doesn't just go around happily comparing exception messages to strings if there is another method to detect the exception.
Back then, error was a glorified string. Then it started having more smart errors, mostly due to a popular third party packages, and then the logic of those popular packages was more or less* put back to go.
* except for stacktraces in native errors. I understand that they are not there for speed reasons but dang it would be nice to have them sometimes
Pessimistically this is yet another example of the language's authors relearning why other languages have the features they do one problem at a time.
(just kidding, they're not mediocre, but they're not infallible or perfect either)
https://github.com/golang/go/pull/49359/files
Before that, doing a string compare was basically the only way to detect that specific error. That was definitely an omission on the part of the original authors of the stdlib code; I don't it should be classified as "Hyrum's Law".
That said, I'd say this is an excellent candidate to deprecate or warn about now, and to make impossible in a version 2. Then again, how would you even stop this? A string representation of an error is common in any language, you need it to log things.
I think at best there will be a static analysis rule (in e.g. go vet) that tries to figure out if any matching is done on the string representation of an error.
First they'd need to export the errors the stdlib returns https://news.ycombinator.com/item?id=41507714
I wouldn't hold my breath on that one.
1 - Lang/OS/Lib developer puts out a quirky or buggy API (or even just an ok API)
2 - Developers rely on a quirky, weird or unexpected side effect because it's easier/more obvious or it just works this way due to a bug
3 - Original developer can't fix it because it would break compatibility
4 GOTO 1
- We randomly read an extra byte from random streams in various GenerateKey functions (which are not marked like the ones in OP) with MaybeReadByte [2] to avoid having our algorithm locked in
- Just yesterday someone reported that a private ECDSA key with a nil public key used to work, and now it doesn't, so we probably have to make it work again [3]
- Iterating over a map uses a randomized order to avoid exposing the internals
- The output of rand.Rand is considered part of the compatibility promise, so we had to go to great lengths to improve it [4]
- We discuss all the time what commitments to make in docs and what behaviors to disclaim, knowing we can never change something documented and probably something that's not explicitly documented as "this may change" [6]
[1]: https://go.dev/doc/go1compat
[2]: https://pkg.go.dev/crypto/internal/randutil#MaybeReadByte
[3]: https://go.dev/issue/70468
[4]: https://go.dev/blog/randv2
[5]: https://go.dev/blog/chacha8rand
[6]: https://go-review.googlesource.com/c/go/+/598336/comment/5d6...
Yep, welcome to my life.
I do remember Go making backwards incompatible changes in some rare scenarios like that.
(and technically the loopvar fix was a big backwards incompatible change; granted that was done with a lot of consideration)
I don't see how this can be a security risk, but allowing a public key that has a curve but a nil value is definitely a messy API.
I would add as a slight caveat that to benefit from this policy, users absolutely must read the release notes on major go versions before upgrading. We recently didn't, and we were burnt somewhat by the change to disallow negative serial numbers in the x509 parser without enabling the new feature flag. Completely our fault and not yours, but I add the caveat nevertheless.
I've been meaning to write a "how to safely update Go" post for a while, because the GODEBUG mechanism is very powerful but not well-known and we could build a bit of tooling around it.
In short, you can upgrade your toolchain without changing the go.mod version, and these things will keep working like they did, and set a metric every time the behavior would have changed, but didn't. (Here's where we could build a bit of tooling to check that metric in prod/tests/CLIs more easily.) Then you can update the go.mod version, which updates the default set of GODEBUGs, and if anything breaks, try reverting GODEBUGs one by one.
Breaking changes in major version updates is a completely normal thing in most software and we usually check for it. Ironically the only reason we weren't previously bothering in go is that the maintainers were historically so hyper-focused on absolute backwards compatibility that there were never any breaking changes!
You don't seem to do that in ed25519. Back before ed25519.NewKeyFromSeed() existed, that was the only way to derive a public Ed25519 key from a private key, and I'm pretty sure I've written code that relied on that (it's easy to remember, since I wasn't very happy about it, but this was all I could do). The documentation of ed25519.GenerateKey mentions that the output is deterministic, so kudos for that. It seems you've really done a great job with investigating and maintaining ossified behavior in the Go cryptography APIs and preventing new ones from happening.
IMO this is a worthwhile tradeoff. I use Go a lot and love the strong backwards compatibility, but I would happily accept a (slightly) higher rate of breaking changes if it meant greater freedom for the Go devs to improve performance, add features etc.
Based on the kind of hell users of other ecosystems seem willing to tolerate (cough Python cough), I believe I am not alone in this viewpoint.
Having bugs imposed on you from outside your project is a waste of time to deal with and there are dozens of other languages you can pick from if you enjoy that time sink. Most of them give you greater capabilities as the balance.
Go's stability is a core feature and compensates for the lack of other niceties. Adding features isn't a good reason to break things. I can go use something else if I want to make that trade.
I’d call your bluff.
Breaking API changes in a minor version update sucks and is often an unexpected time sink, and often mandatory because it has some security patch, critical bug fix, or something.
Breaking API changes in a major version update is expected, can be planned for, and often can be delayed if one chooses.
I exist in a polyglot environment and we use Go for things that we expect to sit and do their job for years without modification.
Nothing more annoying with these than needing to update a runtime to patch a CVE and suddenly needing to invest two weeks to handle all the breaking changes. Go lets us take 5 minutes to bump the version number in the Dockerfile and CI configs and move on to more important work.
I'm not suggesting we'd go rewrite all of those if Go relaxed its guarantees but we'd stop picking it to write new things in and it would slowly disappear as we decommission the existing services over the years.
When it comes to ecosystems, the opinions have trade-offs. I would say that Go's approach to dependencies, modules and workspaces is one of those. As a language it mostly stays out of your way, but correcting imports because it pulled in the wrong version, or dealing with go.mod, go.work and replace directives in a monorepo, gets old pretty fast (to the extent it's easier to just have a monorepo-wide go.mod with literally every dependency in it). At least it's an improvement over having to use a fairly specific directory structure though.
Don’t couple your tests, kids!
A bit trickier in this case no doubt; and trade offs. Ive not minded the React updates over the years, but busting out the Go code I wrote many years ago and having it still run flawlessly is amazing too.
EDIT.
I think I found the source: https://www.rfc-editor.org/rfc/rfc9000#section-17.2.1
> The value in the Unused field is set to an arbitrary value by the server. Clients MUST ignore the value of this field. [...] Note that other versions of QUIC might not make a similar recommendation.
I think they call it "greasing", to prevent "ossification".
This is a reference to RFC 8701, which coined the acronym GREASE ("Generate Random Extensions And Sustain Extensibility"), first in the context of TLS.
https://www.rfc-editor.org/rfc/rfc8701.html
(The earliest draft of the RFC dates back to mid-2016, which is likely the first public mention of the term: https://datatracker.ietf.org/doc/html/draft-davidben-tls-gre...)
Nothing like waking up after 10 years, realize you now really need those bits, and 20 different routers from 10 brands have decided that those bits must be a certain way.
Bonus points for checksums/crypto that breaks on the other end if the bits have been messed with. Curse those middle-boxes and their “clever hacks”.
However, in this specific instance, even if the text cannot be changed, couldn't the error itself in the server be processed and signaled differently, eg. by returning a Status Code 413[1], since clients ought to recognize that status code anyway?
[1]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413
As relates to infinite context, if one pairs the above with some kind of intelligent "solution-checker," it's interesting if models may be able to provide value across absolute monstrous text sizes where it's critical to tie two facts that are worlds apart.
Yikes. this kind of defensive posture with respect to Hyrum's law is extreme and absurd. Per Hyrum's Law everything is potentially relied upon by someone, keeping stuff that may be relied upon means you cannot change anything (see this infamous xkcd on this[1])!
Thinking that no change is acceptable at all isn't the right take-away from Hyrum's Law: instead you should be ready to have to roll back changes that break people's workflow even when you didn't expected the change to break anything (and it also means that you need to have a way for your users to communicate their issues to you, which definitely isn't something Google is well-known for …).
If you maintain a widely use Free Software library, please consider avoiding breaking changes when possible.
I'm not going to imply you have any obligation to do so, but your users will appreciate it.
Therefore it's unavoidable that what constitutes a "breaking change" is a social contract, not a technical contract, because the alternative is that literally nothing is ever allowed to change. So as a library author, document what parts of your API are guaranteed not to change, be reasonable, and have empathy for your users. And as a library consumer, understand that making undocumented interfaces into load-bearing constructs is done at your own risk, and have empathy for the library authors.
It’s practically a protection racket. Only AWS gets the money.
Also, that’s a bit dismissive for HN.
But let me offer a different perspective: Hyrum’s law is neither a technical contract nor a social contract. It’s an emergent technical property in a sufficiently used system.
How you respond to that emergent property depends on the social context.
If you are a FOSS maintainer, and an optimization speeds up 99.99% of users and requires 0.01% to either fix their code or upgrade to a new API, you ship it.
If you are working at a big tech company, you need both the optimization and breaking 0% of the company. So you will work across teams to find the sweet spot.
If you are an enterprise software company, and you change breaks 0.1% if users, but that user is one of the top 5 contracts, you don’t ship.
This is also a sufficient description of "social contract" for this context.
DOS didn't have any precision clocks either as far as I know (it seems that there's interrupt 1A but it only updates 18 times a second, which is an eternity). Apparently there's 8254 based timer code after a few PC generations.
Windows 95 came up with QueryPerformanceCounter() and that simplified life quite a bit.
Maybe not intentionally.
But there have been several times where I've seen bugs where two tasks are done concurrently, but task A always takes longer than task B, then someone makes A faster, and that exposes some race condition or deadlock that only occurs if A completes before B.
In short, I wouldn't consider emergent behaviours of a machine as part of an intentional interface or any kind of contract and therefore I wouldn't see it as a breaking change, the same as fixing a subtle bug in a function wouldn't be seen as a breaking change even if someone depended on the unintentional behaviour.
I think it's more of a testament to Go's hardcore commitment to backwards compatibility, in this case, than anything else.
But there are still informal limits. If the performance impact is bad enough, (say, 5x slower, or changing a linear algorithm to quadratic), it’s probably going to be reverted anyway. We just don‘t have any practical way of formalizing rough performance guarantees at an API boundary.
Even worse, it's possible to select a new algorithm that improves the best-case and average-case runtimes while degrading the worst-case runtime, so no matter what you do it will punish some users and reward others.
Nobody is rewriting `==` to compare strings in constant time, not because it breaks some kind of API contract, but because it would result in a massive waste of CPU time. The point is, though, that they could. But then they are deciding to sacrifice performance for this one problem.
Crypto is obviously a case of it own when it comes to optimisations and as much as I called out the parent for approaching the absurd, we can pull out many similar special cases of our own.
Well yeah, that's pretty much the textbook example of Hyrum's Law (or some funnier variation like "I was relying on the heat from the CPU to warm my bedroom, can you please revert your change that improved CPU performance").
I agree with your point, but that's poor example because you can't rely on function's speed reliably and easily.
Timing differs between hw, OS, OS updates, whatever.
Meanwhile it is trivial and easy to rely on error messages.
And that’s how put Hyrums law into effect.
// isValidNumber reports whether s is a valid JSON number literal. // // isValidNumber should be an internal detail, // but widely used packages access it using linkname. // Notable members of the hall of shame include: // - github.com/bytedance/sonic
That being said, you can take proactive steps to defeat this. For example, the default Hash for strings in .NET is randomly seeded each time a process starts[1] in order to strongly dissuade folks from taking an implicit dependency on the underlying algorithm which is not guaranteed to be stable
[0] : https://en.wikipedia.org/wiki/Protocol_ossification
[1] : https://andrewlock.net/why-is-string-gethashcode-different-e...
Just randomly change the non-guaranteed stuff in every release and this behavior likely would stop and/or you'd lose the users that don't know any better. Both sides of that sound like a win to me.
Most of improvements are "additions", it is never a "change" or "re-do something better"
It is an awesome experience be able to upgrade the language anytime with no fear or pain.
[edit]
Per [2], this looks desire paths is even more an apt analogy than I thought; until 3 years ago, this code returned a generic Error type.
> be conservative in what you send, be liberal in what you accept
If you are liberal in what you accept, you'd better understand the ways in which you've been liberal, and document them (at least) internally, because you're going to have to support all those ways forever, even after huge codebase changes, due to Hyrum's Law.
I try to avoid creating APIs which are "liberal in what they accept" for exactly that reason.
That's my preference too. When you have relaxed criteria about what kind of data you accept via an API I find you inevitably end up having to make decisions about how to massage that data in to some sort of canonical format, and those decisions almost always seem to end up leading to behaviour that's surprising to users in one way or another.
1. Clients will do whatever they need to do get their job done, even if it's not the publisher's intended way
2. Clients don't read documentation
3. Bugs will become part of API once enough clients rely on their behavior
4. The number of API calls does not necessarily equate to importance.
---
As such, I aim for the following when developing an API
1. Ship the beta API early and see how they use it to minimize the surprise. (This may not always be possible)
2. In most cases, bump up the major version while supporting the previous version. This means you'll need to define SLA for your API
3. Most clients are OK with breakages as long as they are given enough time to migrate, or the API provider gives them a tool to auto-migrate the code (if that's possible in your product)
go team did their best to define a custom MaxSizeError to discourage developers from the flimsy string dependency.
Every system hits a limit on the amount of guard rails and protections needed to protect foolish customers from their own bad behavior.
Sometimes you need to deliberately break dependencies that were never meant to exist to reveal the vulnerabilities in a system.
[1]: <https://medium.com/pageup-tech/update-on-driving-client-resi...>
I've seen tests of various types (unit, integration end-to-end) break because they made assumptions about behaviors that weren't garanteed, and supposedly backwards compatible updates broke them. Here are some examples of things that have broken tests:
- an update resulted in a change in the order of elements in a Hashmap or set.
- a change in an error message (or other user-facing message), changed
- a change in how leap days are handled for datetime arithmetic
- change in the format of locale-specific datetimes
- the timezone offset for a given area
- removal of internal-only APIs that were accessed using reflection
- something performed faster, which revealed race conditions in the testing code
- changing the precise representation of some data format. To give a specific example, changing a single byte when gzip compressing a file, that has no impact on the compressed content.
If we can change a misused API such that things break only for the misusing developer, then that is usually a who-cares.
Sometimes that developer is in the same organization that you're in, and their users are your users.
Sometimes that developer is no longer in your organization and their code is your code.