Skip to content

Commit

Permalink
feat(netcore): implement and use error classification
Browse files Browse the repository at this point in the history
This diff implements and uses error classification to reduce
errors to a narrow enumeration set.

We keep the original error into the structured log, which
helps with debugging what happened in case we cannot map
the error that occurred with an enumerated error.

We're not using OONI errors because they are a bit weird
and have some legacy. In any case, we will be able to map
to OONI errors, shall we ever decide to reshaphe the results
and submit them as OONI measurements.
  • Loading branch information
bassosimone committed Nov 29, 2024
1 parent 83d898f commit c96b616
Show file tree
Hide file tree
Showing 9 changed files with 392 additions and 10 deletions.
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

0 comments on commit c96b616

Please sign in to comment.