Settings

Theme

What canceled my Go context?

rednafi.com

101 points by mweibel 4 days ago · 59 comments

Reader

lemoncucumber a day ago

It’s great that they identified this (incredibly common) pain point and introduced a way to solve it, but I can’t help being disappointed.

Reading the examples I found myself thinking, “that looks like a really useful pattern, I should bookmark this so I can adopt it whenever I write code like that.”

The fact that I’m considering bookmarking a blog post about complex boilerplate that I would want to use 100% of the times when it’s applicable is a huge red flag and is exactly why people complain about Go.

It feels like you’re constantly fighting the language: having to add error handling boilerplate everywhere and having to pass contexts everywhere (more boilerplate). This is the intersection of those two annoyances so it feels especially annoying (particularly given the nuances/footguns the author describes).

They say the point is that Go forces you to handle errors but 99% of the time that means just returning the error after possibly wrapping it. After a decade of writing Go I still don’t have a good rule of thumb for when I should wrap an error with more info or return it as-is.

I hope someday they make another attempt at a Go 2.0.

  • grey-area a day ago

    I agree go’s error handling feels a bit clunky, though I prefer the local error handling and passing up the chain (if it were a bit more ergonomic) to exceptions, which IMO have a lot of other problems.

    The main problems seem to me to be boilerplate and error types being so simplistic (interface just has a method returning a string). Boilerplate definitely seems solvable and a proper error interface too. I tend to use my own error type where I want more info (as in networking errors) but wish Go had an interface with at least error codes that everyone used and was used in the stdlib.

    My rule of thumb on annotation is default to no, and add it at the top level. You’ll soon realise if you need more.

    How would you fix it if given the chance?

    • rednafi 20 hours ago

      In an HTTP server, top level means the handlers, is that so?

      • grey-area 19 hours ago

        Yes I guess I do annotation in two places - initial error deep in libraries is annotated, this is passed back up to the initial handlers who log and respond and decide what to show users. Obviously that’s just a rule of thumb and doesn’t always apply.

        Depends if it can be handled lower (with a retry or default data for example), if it can be it won’t be passed all the way up.

        Generally though I haven’t personally found it useful to always annotate at every point in the call chain. So my default is not to annotate and if err return err.

        What I like about errors instead of exceptions is they are boring and predictable and in the call signature so I wouldn’t want to lose that.

    • 9rx 14 hours ago

      > I agree go’s error handling feels a bit clunky

      It should be the same handling as all other types. If it feels clunkier than any other type, you've not found a good design yet. Keep trying new ideas.

      • grey-area 12 hours ago

        Well two things to me feel clunky, first is less serious but leads to lots of verbosity:

        1. if err != nil is verbose and distracting and happens a lot. I'd prefer say Ian Lance Taylor's suggestion of something like this where you're just going to return it vs standard boilerplate which has to return other stuff along with the error:

        // ? Returns error if non-nil, otherwise continue

        data, err := os.ReadFile(path) ?

        // Current situation

        data, err := os.ReadFile(path)

        if err != nil {

          return x,y,z,err
        
        }

        The second is a problem of culture more than anything but the stdlib is to blame:

        2. The errors pkg and error interface has very basic string-based errors. This is used throughout the stdlib and of course in a lot of go code so we are forced to interact with it. It also encourages people to string match on errors to identify them etc etc. Yes you can use your own error types and error interfaces but this then creates interop problems and inevitably many pkgs you use return the error interface. I use my own error types, but still have to use error a lot due to stdlib etc. The wrapping they added and the annotation they encourage is also pretty horrible IMO, returning a bunch of concatted strings.

        So these are not things that end users of the language can fix. Surely we can do better than this for error handling?

        • 9rx 12 hours ago

          > if err != nil is verbose and distracting and happens a lot.

          if err != nil is no more or less verbose than if x > y. You may have a point that Go could do branching better in general, but that isn't about errors specifically.

          If there is something about errors that happening a lot then that still questions your design. Keep trying new ideas until it isn't happening a lot.

          > Surely we can do better than this for error handling?

          Surely we can do better for handling of all types? And in theory we can. In practice, it is like the story of generics in Go: Nobody smart enough to figure out a good solution wants to put in the work. Google eventually found a domain expert in generics to bring in as a contractor to come up with a design, but, even assuming Google is still willing to invest a lot of money in the new budget-tightening tech landscape, it is not clear who that person is in this case.

          Ian Lance Taylor, as you mention, tried quite hard — with work spanning over many years — in both in both cases to find a solution, which we should commend him for, but that type of design isn't really his primary wheelhouse.

          • saghm 3 hours ago

            > if err != nil is no more or less verbose than if x > y. You may have a point that Go could do branching better in general, but that isn't about errors specifically.

            In practice though, there's not nearly as many cases where someone needs to repeat `if x > y { return x }` a bunch of times in the same function. Whether the issue is "about errors" specifically doesn't really change the relatively common view that it's an annoying pattern. It's not surprising that some people might be more interested in fixing the practical annoyance that they deal with every day even if it's not a solution to the general problem that no one has made progress on for over a decade.

  • sethammons 20 hours ago

    > After a decade of writing Go I still don’t have a good rule of thumb for when I should wrap an error with more info or return it as-is.

    The rule of thumb is to wrap always.

    • rednafi 20 hours ago

      Then it results in an absurd amount of duplication. I regularly encounter error strings like:

      error:something happened:error:something happened

      • sethammons 20 hours ago

        Yes, and that is desired.

        Error: failed processing order: account history load failure: getUser error: context deadline exeeded

        • rednafi 19 hours ago

          Your example shows an ideal case w/o repetition. If every layer just wraps error without inspecting, then there will be duplication in the error string.

          • sethammons 19 hours ago

            I have never seen that. I have shipped multiple dozens of services at half a dozen companies. Big code bases. Large teams. Large volumes of calls and data. Complicated distributed systems.

            I am unable to imagine a case where an error string repeated itself. On a loop, an error could repeat, but those show as a numerical count value or as separate logs.

        • grey-area 19 hours ago

          I’d find Error: failed processing order: context deadline exceeded just as useful and more concise.

          Typically there is only one possible code path if you can identify both ends.

          • sethammons 18 hours ago

            Not in my experience. Usually your call chain has forks. Usually the DoThing function will internally do 3 things and any one of those three things failed and you need a different error message to disambiguate. And four methods call DoThing. The 12 error paths need 12 uniquely rendered error messages. Some people say "that is just stack traces," and they are close. It is a concise stack trace with the exact context that focuses on your code under control.

            • grey-area 18 hours ago

              If you have both the start of the call chain and the end of the call chain mapped you will get a different error response almost every time and it is usually more than enough, so say your chain is:

              Do1:...Do10, which then DoX,DoY,DoZ and one of those last 3 failed.

              Do you really need Do1 to Do10 to be annotated to know that DoY failed when called from Do1? I find:

              Do1:DoZ failed for reason bar

              Just as useful and a lot shorter than: Do1: failed:Do2:failed...Do9 failed:Do10:failed:DoZ failed for reason bar

              It is effectively a stack trace stored in strings, why not just embed a proper stack trace to all your errors if that is what you want?

              Your concern with having a stack trace of calls seems a hypothetical concern to me but perhaps we just work on different kinds of software. I think though you should allow that for some people annotating each error just isn't that useful, even if it is useful for you.

        • dale-cooper 16 hours ago

          This feels like manually written stacktraces

      • hellcow 17 hours ago

        After a decade of writing go, I always wrap with the function name and no other content. For instance:

        do c: edit b: create a: something happened

        For functions called doC, editB, createA.

        It’s like a stack trace and super easy to find the codepath something took.

        • MrDarcy 16 hours ago

          I have a single wrap function that does this for all errors. The top level handler only prints the first two, but can print all if needed.

          I have never had difficulty quickly finding the error given only the top two stack sites.

          Any complaint about go boilerplate is flawed. The purpose and value is not in reducing code written, it is to make code easier to read and it achieves this goal better than any other language.

          This value is compounding with coding agents.

  • rednafi a day ago

    Author here. I absolutely hated writing this piece after shooting myself in the foot a thousand times.

    Go's context ergonomics is kinda terrible and currently there's no way around it.

    • lemoncucumber a day ago

      It was a great piece and I learned a lot, thanks for writing it. I hope you didn’t think that it was you I was disappointed with rather than the language designers :)

      It’s ironic how context cancellation has the opposite problem as error handling.

      With errors they force you to handle every error explicitly which results in people adding unnecessary contextual information: it can be tempting to keep adding layer upon layer of wrapping resulting in an unwieldy error string that’s practically a hand-rolled stacktrace.

      With context cancellation OTOH you have to go out of your way to add contextual info at all, and even then it’s not as simple as just using the new machinery because as your piece demonstrates it doesn’t all work well together so you have to go even further out of your way and roll your own timeout-based cancellation. Absurd.

      • rednafi 20 hours ago

        No worries. Your intent was clear. I don't mind the boilerplates if they were footgun free. Context requires you write a bunch of boilerplate where it's still really easy to make mistakes.

  • lenkite a day ago

    I close my nose and always wrap errors with a sentinel error for public functions/methods so that callers can check with `errors.Is`. And you can always identify the place in the call-stack where the error occurred.

    I need to start getting used to context with cancel cause - muscle memory hasn't changed yet.

  • XorNot a day ago

    There are two things I think you could have as implict in Go - error values, and contexts.

    Just pass along two hidden variables for both in parameters and returns, and would anything really change that the compiler wouldn't be able to follow?

    i.e. most functions return errors, so there should always be an implicit error return possible even if I don't use it. Let the compiler figure out if it needs to generate code for it.

    And same story for contexts: why shouldn't a Go program be a giant context tree? If a branch genuinely doesn't ever use it, the compiler should be able to just knock the code out.

    • maleldil a day ago

      What's the difference between an implicit error and exceptions? Being explicit about errors is good. Go's syntactical implementation, coupled with its unexpressive type system, is the problem.

      • XorNot a day ago

        I will freely go on the record as saying that there's nothing wrong with exceptions for this exact reason: errors are so common that a function being "pure" is the exception, and that errors-as-value handling invariable turns into an endless chain of something like "if err; return (nil/zero, err)" in every language which tries it.

        The same would apply to anytime you have Result types - ultimately its still just syntactic sugar over "if err then...".

        What's far more common in real programs is that an error can occur somewhere where you do not have enough context to handle or resolve it, or you're unaware it can happen. In which case the concept of exceptions is much more valid: "if <bad thing here> what do I want to do?" usually only has a couple of places you care about the answer (i.e. "bad thing happened during business process, so start unwinding that process" and many more where the answer is either "crash" or "log it and move on to the next item".

        • kubb a day ago

          Exceptions can be bad if done the wrong way. But the solution isn’t to not deal with it and put it on the programmer. That’s laziness.

          The problems are that the signature of functions doesn’t say anything about what values it might throw, and that sometimes the control flow is obscured — an innocuous call throws.

          Both of these are solvable.

          • XorNot 21 hours ago

            Sure but that also feels like a compiler problem. The compiler knows everywhere my function can go. So rather then having it just throw an exception - i.e. arbitrary data - on the stack, surely what's really happening is I'm creating a big union of "result | error[type,type,type,type]" which only gets culled when I add my "exception" handling.

            My argument here would be, that all of this though doesn't need to be seen unless its relevant - it seems reasonable that the programmer should be able to write code for the happy path, implicitly understanding there's an error path they should be aware of because errors always happen (I mean, you can straight up run out of memory almost anywhere, for example).

            • kubb 21 hours ago

              I agree, and I think that the simplicity mantra of the early go team caused them to not deal with solvable problems when they had the chance.

              They would rather not solve it, thinking that the "programmers will deal with it".

              Now they claim it’s too late.

  • themafia 13 hours ago

    > It feels like you’re constantly fighting the language

    I disagree. I feel like I constantly understand precisely what the language is and is not going to do. This is more valuable to me than languages with 100 sigils that all invoke some kind of "magic path" through my code.

    > forces you to handle errors but 99% of the time that means just returning the error after possibly wrapping it

    How do you universally handle an inventory error? The _path_ to and from the error is more important than the error or it's handling clauses.

    > After a decade of writing Go I still don’t have a good rule of thumb for when I should wrap an error with more info or return it as-is.

    Isn't the point of the above that no matter which you choose the code is mostly the same? How much of an impact is this to refactor when you change your mind? For me it's almost zero. That right there is why I use go.

  • 9rx 16 hours ago

    > After a decade of writing Go I still don’t have a good rule of thumb for when I should wrap an error with more info or return it as-is.

    When writing your tests:

    1. Ensure all error cases are identifiable to the caller — i.e. using errors.Is/errors.AsType

    2. Ensure that you are not leaking the errors from another package — you might change the underlying package later, so you don't want someone to come to depend on it

    As long as those are satisfied, it doesn't matter how it is implemented.

  • DeathArrow 21 hours ago

    Go is seen as too boiler plate-ish, and no one likes that. But one of the biggest Go's biggest assets is its simplicity. And it might not be possible to have both simplicity and low boiler plate.

    I quite enjoy C# and F# and while they are low boiler plate, you can really learn them in a week or two the way you can learn Go.

    And even you don't know anything about Go, you can literally jump into the code base and understand and follow the flow with ease - which quite amazes me.

    So unfortunately, every language has trade offs and Go is not an exception.

    I can't say I enjoy Go as a language but I find it very, very useful.

    And since many people are using LLMs for coding these days, the boiler plate is not as much an issue since it be automated away. And I rather read code generated in Go than some C++ cryptic code.

  • pjmlp 12 hours ago

    Go 2.0 already exists, Java, D, C#, Swift, F#, OCaml,....

    The community is special and now with the original authors mostly gone, and AI into the mix, I don't see it ever happen.

    We will get ridiculous Go 1.xyzabc version numbers.

    • ErroneousBosh 10 hours ago

      Go compiles to machine-native language.

      Java, C# and so on are scripting languages that compile to bytecode that's then run by a painfully slow interpreter.

      • pjmlp 10 hours ago

        Not at all, information is out there in case you want to properly educate yourself on what dynamic compilers are, and what AOT options exist since the 2000's.

ashishb a day ago

Context cancellation (and it's propagation) is one of the best features in Go.

Is there any equivalent in major popular languages like Python, Java, or JS of this?

  • nh2 a day ago

    Haskell is the king of cancellation. Using asynchronous exceptions, you can cancel anything, anytime, with user -defined exception types so you know what the cancellation reason is.

    Example:

        maybeVal <— timeout 1000000 myFunction
    
    Some people think that async exceptions are a pain because you nerd to be prepared that your code can be interrupted any time, but I think it's absolutely worth it because in all the other languages I encounter progress bars that keep running when I click the cancel button, or CLI programs that don't react to CTRL+C.

    In Haskell, cancellability is the default and carries no syntax overhead.

    This is one of the reasons why I think Haskell is currently the best language for writing IO programs.

    • ashishb a day ago

      How do it work inside `myFunction1` which is invoked by `myFunction`? Does `myFunction1` needs to be async as well?

  • ndriscoll a day ago

    ZIO in Scala tracks this sort of thing except you don't have to remember to pass around or select on the ctx (it's just part of the fibre/"goroutine"); if it's cancelled, the fibre and its children just stops the next time it yields (so e.g. if it "selects" on anything or does any kind of IO).

  • deathanatos a day ago

    Python async tasks can be cancelled. But, I don't think you can attach must context to the cancel (I think you can pass a text message), so it would seem the argument of what go suffered from would apply.

    (I also think there's some wonkiness with and barriers to understanding Python's implementation that I don't think plagues Go to quite the same extent.)

    • NeutralForest 21 hours ago

      The current meta is to use task groups and bubble up the exception that cancelled the coroutine/task.

  • lifis a day ago

    All mainstream languages have it in one or more forms (either direct task I/O cancellation, or cancellation tokens or I/O polling that can include synthetic events) since otherwise several I/O patterns are impossible

  • perfmode a day ago

    one of the reasons why i love writing control planes in Go.

  • nnx a day ago

    in JS, signals and AbortController can replicate some of the functionality but it's far less ergonomic than Go.

    https://github.com/ggoodman/context provides nice helpers that brings the DX a bit closer to Go.

  • drdaeman a day ago

    C# has CancellationToken, but it’s just for canceling operations, not a general purpose context.

  • richbell a day ago

    Kotlin Coroutine's structured concurrency. Cancelling a parent automatically cancels child jobs, unless explicitly handled not to. https://kotlinlang.org/docs/coroutines-basics.html

    • tadfisher a day ago

      Stupidly, child cancellation cancels the parent scope as well, unless the scope opts out by including SupervisorJob.

  • Quekid5 a day ago

    Java's Virtual Threads (JVM 21) + the Structured Concurrency primitives (not sure exactly what's available in Java 21+) do this natively.

    Also, a sibling poster mentioned ZIO/Scala which does the Structured Concurrency thing out of the box.

  • gzread a day ago

    Not really, since they don't have `select`

    There's a stop_token in some Microsoft C++ library but it's not nearly as convenient to interrupt a blocking operation with it.

malklera 11 hours ago

I am new enough to programming that I actually have no concrete idea what a context is; I just use it when a library asks for it.

> lemoncucumber

> it can be tempting to keep adding layer upon layer of wrapping resulting in an unwieldy error string that’s practically a hand-rolled stacktrace

I thought this was the whole reason to wrap errors, to know where they passed up the chain.

Funny how it seems no matter the subject, if Go is involved, errors get discussed.

can3p 18 hours ago

I think this post needs better examples to show case the issue, because right now the issue is not clear. Ideally you would need an example that uses the context.Cause function, see below

The contexts and errors communicate information in different directions. Errors let upstream function know what happened within the call, context lets downstream functions know what happened elsewhere in the system. As a consequence there isn't much point to cancel the context and return the error right away if there isn't anybody else listening to it.

Also, context can be chained by definition. If you need to be able to cancel the context with a cause or cancel it with a timeout, you can just make two context and use them.

Example that shows the approach as well as the specific issue raised by the post: https://go.dev/play/p/rpmqWJFQE05

Thanks for the post though! Made me think about contexts usage more

mstipetic 20 hours ago

Golang returning tuples but not having pattern matching is something I'll never get. I really feel it's a too dumbed down version of erlang/elixir, especially with this context passing business

sethammons 20 hours ago

Le sigh. "I don't wrap errors and so I don't know where my errors come from."

The code that justifies the special context handling:

    if err := chargePayment(ctx, orderID); err != nil {
        cancel(fmt.Errorf(
            "order %s: payment failed: %w", orderID, err,
        ))
        return err
    }
Why not simply wrap that error with the same information?
gethly a day ago

Never needed this. Nice to have but won't ever use it.

NeutralForest 21 hours ago

You're kind of building a stack of exceptional cases... Wonder what that is :D

Keyboard Shortcuts

j
Next item
k
Previous item
o / Enter
Open selected item
?
Show this help
Esc
Close modal / clear selection