Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(netcore): implement and use error classification #48

Merged
merged 1 commit into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions errclass/errclass.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
// SPDX-License-Identifier: GPL-3.0-or-later

/*
Package errclass implements error classification.

The general idea is to classify golang errors to an enum of strings
with names resembling standard Unix error names.

# Design Principles

1. Preserve original error in `err` in the structured logs.

2. Add the classified error as the `errclass` field.

3. Use [errors.Is] and [errors.As] for classification.

4. Use string-based classification for readability.

5. Follow Unix-like naming where appropriate.

6. Prefix subsystem-specific errors (`EDNS_`, `ETLS_`).

7. Keep full names for clarity over brevity.

8. Map the nil error to an empty string.

# System and Network Errors

- [ETIMEDOUT] for [context.DeadlineExceeded], [os.ErrDeadlineExceeded]

- [EINTR] for [context.Canceled], [net.ErrClosed]

- [EEOF] for (unexpected) [io.EOF] and [io.ErrUnexpectedEOF] errors

- [ECONNRESET], [ECONNREFUSED], ... for respective syscall errors

The actual system error constants are defined in platform-specific files:

- unix.go for Unix-like systems using x/sys/unix

- windows.go for Windows systems using x/sys/windows

This ensures proper mapping between the standardized error classes and
platform-specific error constants.

# DNS Errors

- [EDNS_NONAME] for errors with the "no such host" suffix

- [EDNS_NODATA] for errors with the "no answer" suffix

# TLS

- [ETLS_HOSTNAME_MISMATCH] for hostname verification failure

- [ETLS_CA_UNKNOWN] for unknown certificate authority

- [ETLS_CERT_INVALID] for invalid certificate

# Fallback

- [EGENERIC] for unclassified errors
*/
package errclass

import (
"context"
"crypto/x509"
"errors"
"io"
"net"
"os"
"strings"
)

const (
//
// Errors that we can map using [errors.Is]:
//

// EADDRNOTAVAIL is the address not available error.
EADDRNOTAVAIL = "EADDRNOTAVAIL"

// EADDRINUSE is the address in use error.
EADDRINUSE = "EADDRINUSE"

// ECONNABORTED is the connection aborted error.
ECONNABORTED = "ECONNABORTED"

// ECONNREFUSED is the connection refused error.
ECONNREFUSED = "ECONNREFUSED"

// ECONNRESET is the connection reset by peer error.
ECONNRESET = "ECONNRESET"

// EHOSTUNREACH is the host unreachable error.
EHOSTUNREACH = "EHOSTUNREACH"

// EEOF indicates an unexpected EOF.
EEOF = "EEOF"

// EINVAL is the invalid argument error.
EINVAL = "EINVAL"

// EINTR is the interrupted system call error.
EINTR = "EINTR"

// ENETDOWN is the network is down error.
ENETDOWN = "ENETDOWN"

// ENETUNREACH is the network unreachable error.
ENETUNREACH = "ENETUNREACH"

// ENOBUFS is the no buffer space available error.
ENOBUFS = "ENOBUFS"

// ENOTCONN is the not connected error.
ENOTCONN = "ENOTCONN"

// EPROTONOSUPPORT is the protocol not supported error.
EPROTONOSUPPORT = "EPROTONOSUPPORT"

// ETIMEDOUT is the operation timed out error.
ETIMEDOUT = "ETIMEDOUT"

//
// Errors that we can map using the error message suffix:
//

// EDNS_NONAME is the DNS error for "no such host".
EDNS_NONAME = "EDNS_NONAME"

// EDNS_NODATA is the DNS error for "no answer".
EDNS_NODATA = "EDNS_NODATA"

//
// Errors that we can map using [errors.As]:
//

// ETLS_HOSTNAME_MISMATCH is the TLS error for hostname verification failure.
ETLS_HOSTNAME_MISMATCH = "ETLS_HOSTNAME_MISMATCH"

// ETLS_CA_UNKNOWN is the TLS error for unknown certificate authority.
ETLS_CA_UNKNOWN = "ETLS_CA_UNKNOWN"

// ETLS_CERT_INVALID is the TLS error for invalid certificate.
ETLS_CERT_INVALID = "ETLS_CERT_INVALID"

//
// Fallback errors:
//

// EGENERIC is the generic, unclassified error.
EGENERIC = "EGENERIC"
)

// errorsIsMap contains the errors that we can map with [errors.Is].
var errorsIsMap = map[error]string{
context.DeadlineExceeded: ETIMEDOUT,
context.Canceled: EINTR,
errEADDRNOTAVAIL: EADDRNOTAVAIL,
errEADDRINUSE: EADDRINUSE,
errECONNABORTED: ECONNABORTED,
errECONNREFUSED: ECONNREFUSED,
errECONNRESET: ECONNRESET,
errEHOSTUNREACH: EHOSTUNREACH,
io.EOF: EEOF,
io.ErrUnexpectedEOF: EEOF,
errEINVAL: EINVAL,
errEINTR: EINTR,
errENETDOWN: ENETDOWN,
errENETUNREACH: ENETUNREACH,
errENOBUFS: ENOBUFS,
errENOTCONN: ENOTCONN,
errEPROTONOSUPPORT: EPROTONOSUPPORT,
errETIMEDOUT: ETIMEDOUT,
net.ErrClosed: EINTR,
os.ErrDeadlineExceeded: ETIMEDOUT,
}

// stringSuffixMap contains the errors that we can map using the error message suffix.
var stringSuffixMap = map[string]string{
"no answer from DNS server": EDNS_NODATA,
"no such host": EDNS_NONAME,
}

// errorsAsList contains the errors that we can map with [errors.As].
var errorsAsList = []struct {
as func(err error) bool
class string
}{
{
as: func(err error) bool {
var candidate x509.HostnameError
return errors.As(err, &candidate)
},
class: ETLS_HOSTNAME_MISMATCH,
},

{
as: func(err error) bool {
var candidate x509.UnknownAuthorityError
return errors.As(err, &candidate)
},
class: ETLS_CA_UNKNOWN,
},

{
as: func(err error) bool {
var candidate x509.CertificateInvalidError
return errors.As(err, &candidate)
},
class: ETLS_CERT_INVALID,
},
}

// New creates a new error class from the given error.
func New(err error) string {
// exclude the nil error case first
if err == nil {
return ""
}

// attemp direct mapping using the [errors.Is] func
for candidate, class := range errorsIsMap {
if errors.Is(err, candidate) {
return class
}
}

// attempt indirect mapping using the [errors.As] func
for _, entry := range errorsAsList {
if entry.as(err) {
return entry.class
}
}

// fallback to attempt matching with the string suffix
for suffix, class := range stringSuffixMap {
if strings.HasSuffix(err.Error(), suffix) {
return class
}
}

// we don't known this error
return EGENERIC
}
81 changes: 81 additions & 0 deletions errclass/errclass_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package errclass

import (
"crypto/x509"
"errors"
"fmt"
"testing"
)

func TestNew(t *testing.T) {
// testcase is a test case implemented by this function.
type testcase struct {
input error
expect string
}

// start with a test case for the nil error
var tests = []testcase{
{
input: nil,
expect: "",
},
}

// add tests for cases we can test with errors.Is
for key, value := range errorsIsMap {
tests = append(tests, testcase{
input: key,
expect: value,
})
}

// add tests for cases we can test with string suffix matching
for suffix, class := range stringSuffixMap {
tests = append(tests, testcase{
input: errors.New("some error message " + suffix),
expect: class,
})
}

// add tests for cases we can test with errors.As
tests = append(tests, testcase{
input: x509.HostnameError{
Certificate: &x509.Certificate{},
Host: "",
},
expect: ETLS_HOSTNAME_MISMATCH,
})
tests = append(tests, testcase{
input: x509.UnknownAuthorityError{
Cert: &x509.Certificate{},
},
expect: ETLS_CA_UNKNOWN,
})
tests = append(tests, testcase{
input: x509.CertificateInvalidError{
Cert: &x509.Certificate{},
Reason: 0,
Detail: "",
},
expect: ETLS_CERT_INVALID,
})

// add test for unknown error
tests = append(tests, testcase{
input: errors.New("unknown error"),
expect: EGENERIC,
})

// run all tests
for _, tt := range tests {
t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) {
got := New(tt.input)
if got != tt.expect {
t.Errorf("New(%v) = %v; want %v", tt.input, got, tt.expect)
}
})
}
}
24 changes: 24 additions & 0 deletions errclass/unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//go:build unix

// SPDX-License-Identifier: GPL-3.0-or-later

package errclass

import "golang.org/x/sys/unix"

const (
errEADDRNOTAVAIL = unix.EADDRNOTAVAIL
errEADDRINUSE = unix.EADDRINUSE
errECONNABORTED = unix.ECONNABORTED
errECONNREFUSED = unix.ECONNREFUSED
errECONNRESET = unix.ECONNRESET
errEHOSTUNREACH = unix.EHOSTUNREACH
errEINVAL = unix.EINVAL
errEINTR = unix.EINTR
errENETDOWN = unix.ENETDOWN
errENETUNREACH = unix.ENETUNREACH
errENOBUFS = unix.ENOBUFS
errENOTCONN = unix.ENOTCONN
errEPROTONOSUPPORT = unix.EPROTONOSUPPORT
errETIMEDOUT = unix.ETIMEDOUT
)
24 changes: 24 additions & 0 deletions errclass/windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//go:build windows

// SPDX-License-Identifier: GPL-3.0-or-later

package errclass

import "golang.org/x/sys/windows"

const (
errEADDRNOTAVAIL = windows.WSAEADDRNOTAVAIL
errEADDRINUSE = windows.WSAEADDRINUSE
errECONNABORTED = windows.WSAECONNABORTED
errECONNREFUSED = windows.WSAECONNREFUSED
errECONNRESET = windows.WSAECONNRESET
errEHOSTUNREACH = windows.WSAEHOSTUNREACH
errEINVAL = windows.WSAEINVAL
errEINTR = windows.WSAEINTR
errENETDOWN = windows.WSAENETDOWN
errENETUNREACH = windows.WSAENETUNREACH
errENOBUFS = windows.WSAENOBUFS
errENOTCONN = windows.WSAENOTCONN
errEPROTONOSUPPORT = windows.WSAEPROTONOSUPPORT
errETIMEDOUT = windows.WSAETIMEDOUT
)
Loading