2

I am writing a simple REST API in go using gin. I have read many posts and texts about making error handling less repetitive in go, but I cannot seem to wrap my mind around how to do it in gin handlers.

All my service does is run some queries against a database and return the results as JSON, so a typical handler looks like this

func DeleteAPI(c *gin.Context) {
    var db = c.MustGet("db").(*sql.DB)
    query := "DELETE FROM table WHERE some condition"
    tx, err := db.Begin()
    if err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    defer tx.Rollback()
    result, err := tx.Exec(query)
    if err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    num, err := result.RowsAffected()
    if err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    err = tx.Commit()
    if err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    c.JSON(200, gin.H{"deleted": num})
}

As you can see, even this simple handler repeats the same "if err != nil" pattern four times. In a "select" based APIs I have twice as many, since there are potential errors when binding the input data and errors when marshaling the response into JSON. Is there a good way to make this more DRY?

Jonathan Hall
  • 75,165
  • 16
  • 143
  • 189
Mad Wombat
  • 14,490
  • 14
  • 73
  • 109
  • 1
    This is by design. Go is verbose. You can extract the logic like Adrian did, but there isn't a magic construct to avoid `if err != nil` – Aurelia May 31 '18 at 16:24
  • There seems to be a lot of suggestions floating around about using the interface nature of golang errors and manage error handling of web services in middleware, but I am not sure how to apply these to gin. – Mad Wombat May 31 '18 at 16:27
  • There's not really a good way to handle errors in middleware. The handler doesn't get to return an error, so the only way to do this would be panic/recover, which is a *terrible* way to handle errors like this. – Adrian May 31 '18 at 16:40
  • 1
    I kinda wonder why panic/recover is frowned upon in the go community. What is the reason not to use it? – Mad Wombat May 31 '18 at 16:43
  • @MadWombat: Read [this](https://dave.cheney.net/2012/01/18/why-go-gets-exceptions-right). But **TL;DR;** exceptions-as-errors (or panic in Go) were a work-around for obsolete language limitations, which don't apply to Go (or many other modern languages). – Jonathan Hall May 31 '18 at 16:44
  • Thanks for the link. That is definitely food for thought. – Mad Wombat May 31 '18 at 16:53
  • @MadWombat You can always make your db code more abstract in such a way that you end up with 3-4 functions and they themselves may have 4 to 8 error checks but you can reuse those functions any time you need to touch the db and so greatly reduce the amount of if-err-not-nil lines. – mkopriva May 31 '18 at 18:00

2 Answers2

5

My normal approach is to use a wrapping function. This has the advantage (over Adrian's answer--which is also a good one, BTW) of leaving the error handling in a more Go-idiomatic form (of return result, err, as opposed to littering your code with handleError(err) type calls), while still consolidating it to one location.

func DeleteAPI(c *gin.Context) {
    num, err := deleteAPI(c)
    if err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    c.JSON(200, gin.H{"deleted": num})
}

func deleteAPI(c *gin.Context) (int, error) {
    var db = c.MustGet("db").(*sql.DB)
    query := "DELETE FROM table WHERE some condition"
    tx, err := db.Begin()
    if err != nil {
        return 0, err
    }
    defer tx.Rollback()
    result, err := tx.Exec(query)
    if err != nil {
        return 0, err
    }
    num, err := result.RowsAffected()
    if err != nil {
        return 0, err
    }
    err = tx.Commit()
    if err != nil {
        return 0, err
    }
    return num, nil
}

For me (and generally, for Go coders), the priority is code readability over DRY. And of the three options (your original, Adrian's, and mine), in my opinion, my version is more readable, for the simple reason that errors are handled in an entirely idiomatic way, and they bubble to the top handler. This same approach works equally as well if your controller ends up calling other functions that return errors. By moving all error handling to the topmost function, you're free from error-handling clutter (other than the simple 'if err != nil { return err }` construct) throughout all the rest of your code.

It's also worth noting that this approach can be powerfuly combined with Adrian's, especially for use with multiple handlers, by changing the "wrapping" function as so:

func DeleteAPI(c *gin.Context) {
    result, err := deleteAPI(c)
    if handleError(c, err) {
        return
    }
    c.JSON(200, gin.H{"deleted": num})
}
Jonathan Hall
  • 75,165
  • 16
  • 143
  • 189
  • This is a good pattern for this particular case, I was aiming for something more generic to "a handler that has to deal with lots of potential errors". I would say, given that `deleteAPI` is now effectively a database handler, it should just take the `sql.DB` as a parameter rather than having a dependency on `gin`. That way it could be moved to a DBAL package that doesn't need to deal with gin at all, just the database. – Adrian May 31 '18 at 16:35
  • @Adrian: I think your handler approach is very powerful--I often use it, and often in conjunction with this one. I've updated my answer now to include the combination as an option. – Jonathan Hall May 31 '18 at 16:35
  • 1
    Yes, I tend to use both - an HTTP handler that uses an error helper, which calls out to a DBAL which consolidates multiple possible DB errors into a single return value. I also sometimes use another error helper within the DBAL that converts DB-specific errors into internal errors so that the HTTP layer doesn't need to know about DB-specific error types. – Adrian May 31 '18 at 16:39
  • @Adrian: I also usually embed HTTP status codes in my errors, which my top-level handler then extracts when sending the response to the client. There's way too much to cover in a single answer :) – Jonathan Hall May 31 '18 at 16:40
  • The unfortunate thing I see in the above code is that if your return value is not an interface, you cannot return nil. So you are forced to return a valid value along with the error. In this case, if I were to abstract my database access methods into a package and someone else were to use them and forgot to check errors, they would have a weird, hard to track down bug where the delete operation might have deleted some records, but 0 was returned. – Mad Wombat May 31 '18 at 17:12
  • @MadWombat: Interfaces aren't the only nilable types. But that shouldn't be a concern--return the type that is appropriate for your use case, and if that's an int (as in this example), just return 0 (or any other throw-away value) when returning an error. This is common practice. – Jonathan Hall May 31 '18 at 17:16
  • 1
    And don't optimize for people who forget to check errors. Such people deserve the problems they get :) (But seriously... it's a TERRIBLE idea to write your code to accommodate people who are expressly writing bad code. And not checking errors is one of the worst things any Go coder can do.) – Jonathan Hall May 31 '18 at 17:17
  • There is a difference. In some other language, I would throw an exception and expect callers to catch/ignore it. Or just let whatever exceptions are thrown in my code to propagate up. This way, if someone doesn't do error checking, they get a nice, fat traceback. In go, if I return nil and an error, I am clearly indicating that something is off and the very next line that tries to use the 'nil' result will bail out. But if I return a valid value and an error, I am relying on other people to actually care and that seems bad. – Mad Wombat May 31 '18 at 18:34
  • In case of deleting records, I can always return -1, since you can never delete a negative number of records, but as a general approach it feels fragile. – Mad Wombat May 31 '18 at 18:36
  • 1
    @MadWombat there's plenty of Go code, including in the standard lib, that together with an error also returns a non-nilable value (the ubiquitous `Read/Write` methods come to mind). There is nothing bad or fragile about returning a non-nilable value alongside an error. What doesn't only seem to be, but actually *is bad*, is other Go programmers not checking errors. – mkopriva May 31 '18 at 19:00
  • What happens in your code if you're trying to delete 10 entities, and 9 are successfully deleted, but there's an error on 1? The idiomatic answer would be to return 9 with an error. Even if you're only deleting 1, and it fails, returning 0 and an error _makes sense_. If some caller doesn't care about errors, but only the count of successful deletions, they _could_ ignore errors, and `0` is meaningful. Don't fight the language. – Jonathan Hall Jun 01 '18 at 06:30
4

You can make it slightly more DRY with a helper:

func handleError(c *gin.Context, err error) bool {
    if err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return true
    }
    return false
}

Used as:

err = tx.Commit()
if handleError(c,err) {
    return
}

This only cuts the error handling line count from 4 lines to 3, but it does abstract away the repeated logic, allowing you to change the repeated error handling in one place instead of everywhere an error is handled (e.g. if you want to add error logging, or change the error response, etc.)

Adrian
  • 42,911
  • 6
  • 107
  • 99
  • This is a bit better, although I would still prefer something more DRY. I will mark your answer if nothing better comes up. – Mad Wombat May 31 '18 at 16:29
  • Unfortunately idiomatic Go includes the pattern of checking errors for each call which might return an error. – Adrian May 31 '18 at 16:30