Skip to content

Commit

Permalink
ADR 023: GQL Compliant Errors (#607)
Browse files Browse the repository at this point in the history
* ADR-023: GQL Compliant Errors

---------

Co-authored-by: Robsdedude <[email protected]>
  • Loading branch information
StephenCathcart and robsdedude authored Oct 14, 2024
1 parent 54c9d19 commit 9a93d3e
Show file tree
Hide file tree
Showing 9 changed files with 338 additions and 46 deletions.
60 changes: 56 additions & 4 deletions neo4j/db/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,63 @@ import (
"strings"
)

// Neo4jError is created when the database server failed to fulfill request.
type ErrorClassification string

const (
ClientError ErrorClassification = "CLIENT_ERROR"
DatabaseError ErrorClassification = "DATABASE_ERROR"
TransientError ErrorClassification = "TRANSIENT_ERROR"
UnknownError ErrorClassification = "UNKNOWN"
)

// TODO: when we no longer need to support the old Neo4jError, rename the following fields:
// - Neo4jError -> GqlError
// - GqlStatusDescription -> StatusDescription
// - GqlClassification -> Classification
// - GqlRawClassification -> RawClassification
// - GqlDiagnosticRecord -> DiagnosticRecord
// - GqlCause -> Cause
// - GqlStatus to remain as-is to comply with GQLSTATUS.

// Neo4jError is created when the database server fails to fulfill a request.
type Neo4jError struct {
Code string
Msg string
// Code is the Neo4j-specific error code, to be deprecated in favor of GqlStatus.
Code string
// Msg is the specific error message describing the failure.
Msg string
// GqlStatus returns the GQLSTATUS.
// GqlStatus is the error code compliant with the GQL specification.
//
// GqlStatus is part of the GQL compliant errors preview feature
// (see README on what it means in terms of support and compatibility guarantees)
GqlStatus string
// GqlStatusDescription provides a standard description for the associated GQLStatus code.
//
// GqlStatusDescription is part of the GQL compliant errors preview feature
// (see README on what it means in terms of support and compatibility guarantees)
GqlStatusDescription string
// GqlClassification is a high-level categorization of the error, specific to GQL error handling.
//
// GqlClassification is part of the GQL compliant errors preview feature
// (see README on what it means in terms of support and compatibility guarantees)
GqlClassification ErrorClassification
// GqlRawClassification holds the raw classification as received from the server.
//
// GqlRawClassification is part of the GQL compliant errors preview feature
// (see README on what it means in terms of support and compatibility guarantees)
GqlRawClassification string
// GqlDiagnosticRecord returns further information about the status for diagnostic purposes.
//
// GqlDiagnosticRecord is part of the GQL compliant errors preview feature
// (see README on what it means in terms of support and compatibility guarantees)
GqlDiagnosticRecord map[string]any
// GqlCause represents the underlying error, if any, which caused the current error.
//
// GqlCause is part of the GQL compliant errors preview feature
// (see README on what it means in terms of support and compatibility guarantees)
GqlCause *Neo4jError
parsed bool
classification string
classification string // Legacy non-GQL classification
category string
title string
retriable bool
Expand All @@ -38,6 +89,7 @@ func (e *Neo4jError) Error() string {
return fmt.Sprintf("Neo4jError: %s (%s)", e.Code, e.Msg)
}

// TODO 6.0: remove in favour of GqlClassification
func (e *Neo4jError) Classification() string {
e.parse()
return e.classification
Expand Down
2 changes: 1 addition & 1 deletion neo4j/internal/bolt/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ type protocolVersion struct {

// Supported versions in priority order
var versions = [4]protocolVersion{
{major: 5, minor: 6, back: 6},
{major: 5, minor: 7, back: 7},
{major: 4, minor: 4, back: 2},
{major: 4, minor: 1},
{major: 3, minor: 0},
Expand Down
33 changes: 21 additions & 12 deletions neo4j/internal/bolt/hydrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ package bolt
import (
"errors"
"fmt"
"github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/errorutil"
"github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/gql"
"time"

idb "github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/db"
Expand Down Expand Up @@ -157,7 +159,7 @@ func (h *hydrator) hydrate(buf []byte) (x any, err error) {
case msgIgnored:
x = h.ignored(n)
case msgFailure:
x = h.failure(n)
x = h.failure(n, false)
case msgRecord:
x = h.record(n)
default:
Expand All @@ -178,27 +180,42 @@ func (h *hydrator) ignored(n uint32) *ignored {
return &h.cachedIgnored
}

func (h *hydrator) failure(n uint32) *db.Neo4jError {
func (h *hydrator) failure(n uint32, isNestedError bool) *db.Neo4jError {
h.assertLength("failure", 1, n)
if h.getErr() != nil {
return nil
}
dberr := db.Neo4jError{}
h.unp.Next() // Detect map
// Skip h.unp.Next() for nested errors to avoid reprocessing the unpacker,
// as it's already handled by the recursive call.
if !isNestedError {
h.unp.Next() // Detect map
}
for maplen := h.unp.Len(); maplen > 0; maplen-- {
h.unp.Next()
key := h.unp.String()
h.unp.Next()
switch key {
case "gql_status":
dberr.GqlStatus = h.unp.String()
case "description":
dberr.GqlStatusDescription = h.unp.String()
case "neo4j_code":
fallthrough
case "code":
dberr.Code = h.unp.String()
case "message":
dberr.Msg = h.unp.String()
case "diagnostic_record":
dberr.GqlDiagnosticRecord = h.amap()
case "cause":
dberr.GqlCause = h.failure(1, true)
default:
// Do not fail on unknown value in map
h.trash()
}
}
errorutil.PolyfillGqlError(&dberr)
if h.boltLogger != nil {
h.boltLogger.LogServerMessage(h.logId, "FAILURE %s", loggableFailure(dberr))
}
Expand Down Expand Up @@ -1003,14 +1020,6 @@ func parseNotification(m map[string]any) db.Notification {
return n
}

func newDefaultDiagnosticRecord() map[string]any {
return map[string]any{
"OPERATION": "",
"OPERATION_CODE": "0",
"CURRENT_SCHEMA": "/",
}
}

func parseGqlStatusObject(m map[string]any) db.GqlStatusObject {
g := db.GqlStatusObject{}

Expand Down Expand Up @@ -1042,7 +1051,7 @@ func parseGqlStatusObject(m map[string]any) db.GqlStatusObject {
}

// Initialize the default diagnostic record
diagnosticRecord := newDefaultDiagnosticRecord()
diagnosticRecord := gql.NewDefaultDiagnosticRecord()

// Merge the diagnostic record from the map m
if dr, ok := m["diagnostic_record"].(map[string]any); ok {
Expand Down
55 changes: 53 additions & 2 deletions neo4j/internal/bolt/hydrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package bolt

import (
"fmt"
"github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/gql"
"math"
"reflect"
"runtime/debug"
Expand Down Expand Up @@ -47,7 +48,7 @@ func TestHydrator(outer *testing.T) {
panic(err)
}

fullDiagnosticRecord := newDefaultDiagnosticRecord()
fullDiagnosticRecord := gql.NewDefaultDiagnosticRecord()
fullDiagnosticRecord["_severity"] = "s1"
fullDiagnosticRecord["_classification"] = "c1"
fullDiagnosticRecord["_position"] = map[string]any{
Expand Down Expand Up @@ -379,7 +380,7 @@ func TestHydrator(outer *testing.T) {
{
GqlStatus: "g2",
StatusDescription: "sd2",
DiagnosticRecord: newDefaultDiagnosticRecord(),
DiagnosticRecord: gql.NewDefaultDiagnosticRecord(),
IsNotification: false,
},
}},
Expand Down Expand Up @@ -1033,6 +1034,56 @@ func TestHydratorBolt5(outer *testing.T) {
}},
}},
},
{
name: "Failure with GQL Error",
build: func() {
packer.StructHeader(byte(msgFailure), 1)
packer.MapHeader(6)
packer.String("gql_status")
packer.String("g1")
packer.String("description")
packer.String("d1")
packer.String("message")
packer.String("m1")
packer.String("neo4j_code")
packer.String("c1")
packer.String("diagnostic_record")
packer.MapHeader(1) // diagnostic record map
packer.String("_classification")
packer.String("CLIENT_ERROR")
packer.String("cause")
packer.MapHeader(3) // nested cause error map
packer.String("gql_status")
packer.String("g2")
packer.String("description")
packer.String("d2")
packer.String("message")
packer.String("m2")
},
x: &db.Neo4jError{
Code: "c1",
Msg: "m1",
GqlStatus: "g1",
GqlStatusDescription: "d1",
GqlClassification: db.ClientError,
GqlRawClassification: "CLIENT_ERROR",
GqlDiagnosticRecord: map[string]any{
"OPERATION": "",
"OPERATION_CODE": "0",
"CURRENT_SCHEMA": "/",
"_classification": "CLIENT_ERROR",
},
GqlCause: &db.Neo4jError{
Code: "Neo.DatabaseError.General.UnknownError",
Msg: "m2",
GqlStatus: "g2",
GqlStatusDescription: "d2",
GqlClassification: db.UnknownError,
GqlRawClassification: "",
GqlDiagnosticRecord: gql.NewDefaultDiagnosticRecord(),
},
},
},
}

hydrator := hydrator{boltMajor: 5, useUtc: true}
Expand Down
51 changes: 51 additions & 0 deletions neo4j/internal/errorutil/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,16 @@ package errorutil
import (
"fmt"
"github.com/neo4j/neo4j-go-driver/v5/neo4j/db"
"github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/gql"
"io"
"net"
)

const unknownNeo4jCode = "Neo.DatabaseError.General.UnknownError"
const unknownMessage = "An unknown error occurred"
const unknownGqlStatus = "50N42"
const unknownGqlStatusDescription = "error: general processing exception - unexpected error."

func CombineAllErrors(errs ...error) error {
if len(errs) == 0 {
return nil
Expand Down Expand Up @@ -119,3 +125,48 @@ func (e *TokenExpiredError) Unwrap() error {
func (e *TokenExpiredError) Error() string {
return fmt.Sprintf("TokenExpiredError: %s (%s)", e.Code, e.Message)
}

func PolyfillGqlError(dberr *db.Neo4jError) {
if dberr.Code == "" {
dberr.Code = unknownNeo4jCode
}
if dberr.Msg == "" {
dberr.Msg = unknownMessage
}
if dberr.GqlStatus == "" {
dberr.GqlStatus = unknownGqlStatus
}
if dberr.GqlStatusDescription == "" {
dberr.GqlStatusDescription = fmt.Sprintf("%s %s", unknownGqlStatusDescription, dberr.Msg)
}
// Ensure diagnostic record exists or fill missing keys
defaultRecord := gql.NewDefaultDiagnosticRecord()
if dberr.GqlDiagnosticRecord == nil {
dberr.GqlDiagnosticRecord = defaultRecord
} else {
// Polyfill any missing keys from default record
for key, value := range defaultRecord {
if _, exists := dberr.GqlDiagnosticRecord[key]; !exists {
dberr.GqlDiagnosticRecord[key] = value
}
}
}
// Extract classification from diagnostic record
if classification, ok := dberr.GqlDiagnosticRecord["_classification"].(string); ok {
// Set the raw classification string
dberr.GqlRawClassification = classification
switch classification {
case string(db.ClientError):
dberr.GqlClassification = db.ClientError
case string(db.DatabaseError):
dberr.GqlClassification = db.DatabaseError
case string(db.TransientError):
dberr.GqlClassification = db.TransientError
default:
dberr.GqlClassification = db.UnknownError
}
} else {
dberr.GqlRawClassification = ""
dberr.GqlClassification = db.UnknownError
}
}
Loading

0 comments on commit 9a93d3e

Please sign in to comment.