-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(netcore): implement and use error classification
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
1 parent
83d898f
commit c96b616
Showing
9 changed files
with
392 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
Oops, something went wrong.