Published on

Error Handling

Authors
  • Name
    Humam Fauzi
    Twitter

Most software system experience error in a way. Software engineer tasked to handle the error properly. There some error that can be fatal; break the system so it no longer work and some error are warning to user who use it. We explore several error handling common found in web development.

There are two kind of error approach. First the bubbling approach where error would propagate to the beginning; bypassing all stack it went through. Second return approach where every error is in return value and its up to the engineers how to handle it. The first approach implemented in Javascript and its derivatives and the second approach implemented in Golang.

Bubbling Approach

Let's say you have an endpoint that would write user profile to database and return its ID to frontend. The database can return various error from disconnect to database, unfulfilled constaraint and others. Let's focus on the first two.

Disconnect from database is an server error. Server should have connected everytime there is a hit to endpoint. Moreover, user cannot do nothing in their end to fix it. In web documentation, this kind of error should belong to 5XX errors.

Errors from database sometimes feel cryptic and potentially have a senstive value like database username, IPs, and other undisclosed information that useful for logging but should be public information. We need to rewrite the error message before we send it to public.

Javascript have try catch finally sequence to handle error. Usually it went this way

try {
	writeDatabase(input)
} catch(e) {
	// handling error
}

Any error that throwed from writeDatabase function would end up in catch(e) enclosure. In this enclosure we can extract error information, classify it, re-routing it, and logging it. We can also rethrow it so other function in upper stack can catch it. If there is no one to catch it, it would return the error in its raw form.

My approach to handle this is to standardize the error. An error is an object is Javascript. You can create your own error. This error preferably can be transformed in to a parse-able form like JSON so platform knows what to show in the frontend.

try {
	writeDatabase(input)
} catch(e) {
	if (e isinstanceof(sql.duplicate))
		throw new UsecaseError(err_duplicate)
	if (e isinstanceof(sql.no_connection))
		throw new UsecaseError(err_no_connection)
	throw new UsecaseError(err_unknown)
}

In above example, we throw a class called UsecaseError with input of err_duplicate or err_no_connection or err_unknown. Each of this usecase represent different error for frontend. err_duplicate means that inputted data is already registered in system, therefore user should change it. Since user able to fix it from their end, we consider it as 4XX errors. err_no_connection is a server error which there are no means user can fix it. We consider it as 5XX errors. The last is the err_unknown means that we don't handle such error. Usually it would displayed as error without much information. A good system should minimize unknown error.

Let's move to the middleware. Each of endpoint can have multiple, different middleware. We can assign different error interpreter as different middleware. For instance, the endpoint for host to host and client to host might have different return error message.

func middleware(req, res) {
	try {
		const result = usecaseFunction(req)
		res.write(result)
	} catch(e) {
		res.headers = e.headers
		res.code = e.code
		res.write(e.body)
	}
}

Every error that happen in usecaseFunction, unless it catched in the middle, would end up in the catch(e) encapsulation. Since we already standardized our error, we can safely extract headers, code and write error body.

Return Approach

Golang is famous for if err != nil approach. Several people dettered to use Go because of this syntax. Everytime a function that possibility of error, you need to add if err != nil. Coming from JavaScript, it seems tedious.

One of the benefit declaring if err != nil in every function is force us to think what would we do if this function return an error. Unlike in bubbling approach where we hope some upper stack would catch our error, Golang force developer to think what is the best way to handle every function error.

We can let the error 'bubble' by returning the error immediately without any modification, it usually look like this

func someFunction(input string) (string, error) {
	result, err := anotherFunction(input)
	if err != nil {
		return "", err
	}
	return result, nil
}

error type in Golang is an interface with one methods Error() string which means any type who has Error() string methods is a valid error type in Golang. This ensure that we can create our custom error.

Transforming an interface to a type can be done using type inferrence example.(desired_type). Go also provide error check errors.Is() similar to instanceof. It compare two error and decide whether they are the same or not.

func getDatabase(input string) (string, error) {
	result, err := db.Database(input)
	if errors.Is(err, sql.ErrNoRows) {
		return "", errors.New("not found")
	}
	if err != nil {
		return "", err
	}
	return result, nil
}

The example above rewrite the error message if the error is no rows found. Each library have their own error.

In Golang, errors can propagate through a mechanism known as panic. While panic can bubble up through the program and be caught, it's typically reserved for situations where the system encounters critical failures and cannot continue executing properly. For instance, imagine a scenario where our system fails to read its configuration file, which contains essential credentials and constants. In such a case, since the system cannot operate without these crucial pieces of information, invoking panic might be appropriate.

Best Approach?

So what is the best approach to error handling? Both have their own merit but two things that need to be clear before implementing it which is standardized error and layering.

Standardized error is your custom error that applied in all you application. All error should be able to take form in standardized error. This makes you able to transform error from one to another form. Since it is a custom error, you can add more information to an error.

Some error are more important than other error. Maybe because it invoked from critical endpoint or and error in certain place implied a fatal mistake. A critical error might trigger an alarm or have a different log signature so we can query it later for postmortem findings.

The second part is layering which require well implemented standardized error. All good application have explicit layering for better control and context. The standard layering system is incoming, usecase, and outer. Some people might have different terms for it.

Incoming is what trigger the usecase. It can be a HTTP call, an RPC call, a pubsub subscription, CRON trigger, etc. An error for HTTP call and RPC call might have different form. The standardized error should be able to handle both error form. If we have other methods of call, we can add new methods to fit it without need to understand how the error structurized. An error in subscription might trigger an acknowledgement event and some error might require a retrigger. Standardized error should be able to handle this.

Usecase is usually where business logic error get thrown. For example, check a zero balance in an account should be in a usecase. If the user have account lower or equal to zero, usecase should throw an error and let middleware transform it to appropriate call.

Outer is where database, cache, third party call located. They usually have their own error in place. We need to transform the error to standardized error.

Standardized Error

What kind of information standardized error should contain? It depends on your incoming and outer layer. Outer layer have their own error that need to be translated to standard error. For instance, the no rows in SQL database can be converted to common error of not_found. This common error can be used when we do call to other API like Google Maps and not found any location we want to search.

class StdError {
	constructor(type, info) {
		this.type = type
		if (!info) {
			info = {}
		}
		this.info = info
	}

	setSource(source) {
		this.source = source
		return this
	}

	setAsCritical() {
		this.is_critical = true
		return this
	}

	setAsShouldAcknowledge() {
		this.should_acknowledge = true
		return this
	}
}

So we can use it as a builder error.

try {
	writeDatabase(input)
} catch(e) {
	if (e isinstanceof(sql.duplicate))
		throw new err.StdError("duplicate")
			.setSource("database")
			.setAsShouldAcknowledge()
	throw new UsecaseError(err_unknown)
}

In Go, we can do this

type errorType string
type StdError struct {
	Type errorType
	Information map[string]any

	Source string
	ShouldAcknowledge bool
}

func (se *StdError) Error() string {
	return string(se.Type)
}

type stdErrorBuilder struct {
	s *StdError
}

func NewError(errType errorType, info map[string]any) {
	placeholder := make(map[string]any)
	if info != nil {
		placeholder = info
	}
	return &stdErrorBuilder{
		s: &StdError{
			Type: errType,
			Information: placeholder,
		}
	}
}

func (seb *stdErrorBuilder) SetSource(source string) *stdErrorBuilder {
	seb.s.Source = source
	return seb
}

func (seb *stdErrorBuilder) SetAsShouldAcknowledge() *stdErrorBuilder {
	seb.s.ShouldAcknowledge = true
	return seb
}

func (seb *stdErrorBuilder) Final() *StdError {
	return seb.s
}

the implementation would be

func getDatabase(input string) (string, error) {
	result, err := db.Database(input)
	if errors.Is(err, sql.ErrNoRows) {
		return "", NewError("not_found", nil).
		SetSource("database").
		SetAsShouldAcknowledge().
		Final()
	}
	if err != nil {
		return "", err
	}
	return result, nil
}

So both can be build with standardized error. What about in the implemenation in the incoming? We need to add more methods to our standard error for handling HTTP.

class StdError {
	// previous implementation
	toHTTP() {
		switch(this.type) {
		case "not_found":
			return {
				headers: {},
				code: 404,
				body: this.info
			}
		} 
	}
}

And it would work like this.

func middleware(req, res) {
	try {
		const result = usecaseFunction(req)
		res.write(result)
	} catch(e) {
		httpError = e.toHTTP()
		res.headers = httpError.headers
		res.code = httpError.code
		res.write(httpError.body)
	}
}

You can add extra conviction with if (e instance of StdError) . What about in Go? Same as above but with different syntax

func middleware(w http.ResponseWriter, r *http.Request) {
	result, err := actualFunction(r)
	if err != nil {
		stdErrs, ok := err.(StdError)
		if !ok {
			// handle default generic error
			return
		}
		w.StatusCode = stdErrs.httpStatusCode()
		w.Body = stdErrs.httpBody()
		
	}
	handleReturn(w, result)
}

We need to add method httpStatusCode and httpBody.

func (se *StdError) httpStatusCode() int {
	switch(se.Type) {
	case NotFound:
		return http.StatusNotFound
	default:
		return http.StatusInternalServerError
	}
}

func (se *StdError) httpBody() []byte {
	byted, _ := json.Marshal(se.Information)
	return byted
}

Conclusion

Whether bubbling error or return error, we need a standard error that can be displayed or interpreted in incoming layer. All the rest of layer should follow standardized layer so that error in each layer can be reused regardless of incoming layer we use.