Skip to content

Commit 22ca69f

Browse files
committed
Add support for SurrealDB v3 structured error handling
Introduce ServerError type for SurrealDB v3 structured errors (Kind, Details, Cause) while preserving backward compatibility with the existing RPCError and QueryError types. ```go _, err := surrealdb.Select[map[string]any](context.Background(), db, models.Table("nonexistent_table_for_test"), ) // Use ServerError on SurrealDB v3 var se surrealdb.ServerError if errors.As(err, &se) { // Get access to new error fields like Kind, Details and Cause } ```
1 parent 89d0f8d commit 22ca69f

File tree

10 files changed

+805
-8
lines changed

10 files changed

+805
-8
lines changed

contrib/surrealrestore/restore_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ func TestRestorerFull(t *testing.T) {
3939
t.Fatalf("Failed to init source db: %v", err)
4040
}
4141

42+
// Check if this is SurrealDB 3.x - skip due to changefeed behavior changes
43+
// This may be revisited once 3.0 GA is out and changefeed behavior is stable
44+
v, vErr := testenv.GetVersion(ctx, sourceDB)
45+
if vErr != nil {
46+
t.Fatalf("Failed to get SurrealDB version: %v", vErr)
47+
}
48+
if v.IsV3OrLater() {
49+
t.Skip("Skipping incremental dump/restore test on SurrealDB 3.x - changefeed behavior has changed significantly")
50+
}
51+
4252
// Insert test data
4353
type TestRecord struct {
4454
ID string `json:"id,omitempty"`

contrib/testenv/structured_errors_integration_test.go

Lines changed: 408 additions & 0 deletions
Large diffs are not rendered by default.

example_server_error_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package surrealdb_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
surrealdb "github.com/surrealdb/surrealdb.go"
9+
"github.com/surrealdb/surrealdb.go/contrib/testenv"
10+
"github.com/surrealdb/surrealdb.go/pkg/models"
11+
)
12+
13+
// ExampleUpsert_server_error demonstrates extracting a *ServerError from
14+
// an RPC error on SurrealDB v3 using errors.As.
15+
//
16+
// On SurrealDB v2, the error is still an *RPCError (backward compatible),
17+
// but errors.As(err, &se) also works because RPCError.Unwrap() returns
18+
// a *ServerError. On v2 servers, se.Kind will be empty.
19+
func ExampleUpsert_server_error() {
20+
db := testenv.MustNew("surrealdbexamples", "server_error", "person")
21+
22+
type Person struct {
23+
Name string `json:"name"`
24+
}
25+
26+
if _, err := surrealdb.Query[any](
27+
context.Background(),
28+
db,
29+
`DEFINE TABLE person SCHEMAFUL;
30+
DEFINE FIELD name ON person TYPE string;`,
31+
nil,
32+
); err != nil {
33+
panic(err)
34+
}
35+
36+
_, err := surrealdb.Upsert[Person](
37+
context.Background(),
38+
db,
39+
models.Table("person"),
40+
map[string]any{
41+
"id": models.NewRecordID("person", "a"),
42+
"name": 123,
43+
},
44+
)
45+
if err != nil {
46+
// v2 backward compat: RPCError is still matchable
47+
fmt.Printf("Error is RPCError: %v\n", errors.Is(err, &surrealdb.RPCError{}))
48+
49+
// v3 migration path: extract ServerError for structured info
50+
fmt.Printf("Error is ServerError: %v\n", errors.Is(err, surrealdb.ServerError{}))
51+
}
52+
53+
// Output:
54+
// Error is RPCError: true
55+
// Error is ServerError: true
56+
}

internal/fakesdb/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ type StubResponse struct {
101101
// Result is the successful RPCResponse result to return (mutually exclusive with Error)
102102
Result any
103103
// Error is the error to return (mutually exclusive with Result)
104+
//nolint:staticcheck //v2 backward compat with RPCError
104105
Error *connection.RPCError
105106
// Failures defines failure injection configurations for this response
106107
Failures []FailureConfig
@@ -567,6 +568,7 @@ func (h *Handler) sendResponse(socket *gws.Conn, id, result any) {
567568
func (h *Handler) sendError(socket *gws.Conn, id any, code int, message string) {
568569
var resp connection.RPCResponse[any]
569570
resp.ID = id
571+
//nolint:staticcheck // intentionally testing v2 backward compat with RPCError
570572
resp.Error = &connection.RPCError{
571573
Code: code,
572574
Message: message,
@@ -630,6 +632,7 @@ func SimpleStubResponse(method string, response any) StubResponse {
630632
func ErrorStubResponse(method string, code int, message string) StubResponse {
631633
return StubResponse{
632634
Matcher: MatchMethod(method),
635+
//nolint:staticcheck // v2 backward compat with RPCError
633636
Error: &connection.RPCError{
634637
Code: code,
635638
Message: message,

pkg/connection/gorillaws/connection_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ func TestHandleResponse_ErrorWithoutID(t *testing.T) {
147147
mockUnmarshal := &mockUnmarshaler{
148148
unmarshalFunc: func(data []byte, v any) error {
149149
if res, ok := v.(*connection.RPCResponse[cbor.RawMessage]); ok {
150+
//nolint:staticcheck // v2 backward compat with RPCError
150151
res.Error = &connection.RPCError{
151152
Code: -32600,
152153
Message: "Invalid Request",

pkg/connection/model.go

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,70 @@
11
package connection
22

3-
// RPCError represents a JSON-RPC error
3+
import (
4+
"errors"
5+
"reflect"
6+
7+
"github.com/fxamacker/cbor/v2"
8+
)
9+
10+
// wireDecMode is a CBOR decode mode that decodes maps with string keys
11+
// to map[string]any instead of map[any]any. This ensures the Details field
12+
// in wireError is always map[string]any for consistent detail helper behavior.
13+
var wireDecMode cbor.DecMode
14+
15+
//nolint:gochecknoinits // init is used to set up the CBOR decode mode for wireError
16+
func init() {
17+
var err error
18+
wireDecMode, err = cbor.DecOptions{
19+
DefaultMapType: reflect.TypeOf(map[string]any(nil)),
20+
}.DecMode()
21+
if err != nil {
22+
panic(err)
23+
}
24+
}
25+
26+
// RPCError represents a JSON-RPC error from the SurrealDB server.
27+
//
28+
// On SurrealDB v3 servers, use errors.As to extract a *ServerError for richer
29+
// structured error information (Kind, Details, Cause).
30+
//
31+
// Deprecated: Use [ServerError] instead on SurrealDB v3 for richer error information.
32+
// TODO(v2-compat): Remove in next major release.
433
type RPCError struct {
5-
Code int `json:"code"`
6-
Message string `json:"message,omitempty"`
34+
// Code is the JSON-RPC numeric error code.
35+
// SurrealDB v2 and v3: Always present for RPC-level errors.
36+
Code int `json:"code"`
37+
38+
// Message is the error message from the server.
39+
// SurrealDB v2 and v3: Always present.
40+
Message string `json:"message,omitempty"`
41+
42+
// Description is a human-readable description of the error.
43+
// SurrealDB v2 only: Not populated by v3 servers.
44+
// Use ServerError on SurrealDB v3 instead.
45+
//
46+
// Deprecated: Not populated by SurrealDB v3 servers.
47+
// TODO(v2-compat): Remove in next major release.
748
Description string `json:"description,omitempty"`
49+
50+
// wire stores the full deserialized error data (v2 and v3 fields).
51+
// RPCError.As delegates to wireError.As for errors.As extraction of *ServerError.
52+
wire *wireError
53+
}
54+
55+
// UnmarshalCBOR deserializes the RPC error from CBOR.
56+
// It first deserializes into wireError (capturing all v2+v3 fields),
57+
// then populates the v2 public fields and creates a ServerError for the v3 view.
58+
func (r *RPCError) UnmarshalCBOR(data []byte) error {
59+
w := &wireError{}
60+
if err := wireDecMode.Unmarshal(data, w); err != nil {
61+
return err
62+
}
63+
r.Code = w.Code
64+
r.Message = w.Message
65+
r.Description = w.Description
66+
r.wire = w
67+
return nil
868
}
969

1070
func (r RPCError) Error() string {
@@ -15,12 +75,16 @@ func (r RPCError) Error() string {
1575
}
1676

1777
func (r *RPCError) Is(target error) bool {
18-
if target == nil {
19-
return r == nil
78+
switch target.(type) {
79+
case RPCError, *RPCError, ServerError, *ServerError:
80+
return true
81+
default:
82+
return false
2083
}
84+
}
2185

22-
_, ok := target.(*RPCError)
23-
return ok
86+
func (r *RPCError) As(err any) bool {
87+
return errors.As(r.wire, err)
2488
}
2589

2690
// RPCRequest represents an incoming JSON-RPC request.

pkg/connection/server_error.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package connection
2+
3+
// ServerError represents a structured error from SurrealDB v3.
4+
// Only use this when you know you are running against a SurrealDB v3 server.
5+
//
6+
// Extract from RPC errors using errors.As:
7+
//
8+
// var se *connection.ServerError
9+
// if errors.As(err, &se) {
10+
// fmt.Println(se.Kind, se.Details)
11+
// }
12+
//
13+
// ServerError carries structured information including Kind, Details, and a cause chain.
14+
// Use the helper functions in the surrealdb package (IsNotAllowed, IsNotFound, etc.)
15+
// for ergonomic kind checking, or inspect Kind directly.
16+
type ServerError struct {
17+
// Code is the JSON-RPC numeric error code.
18+
Code int
19+
20+
// Message is the error message.
21+
Message string
22+
23+
// Kind is the structured error kind (e.g. "NotFound", "NotAllowed").
24+
Kind string
25+
26+
// Details contains kind-specific structured error details.
27+
// SurrealDB v3 only. nil for v2 servers.
28+
//
29+
// In SurrealDB v3, this follows the { "kind": "...", "details": ... } format (internally-tagged).
30+
// In older versions, this may be a string (unit variants) or a map with the
31+
// variant name as key (externally-tagged format).
32+
Details any
33+
34+
// Cause is the underlying error in the cause chain.
35+
Cause *ServerError
36+
}
37+
38+
// Error implements the error interface.
39+
// When a cause chain is present, the messages are joined with ": ".
40+
func (e ServerError) Error() string {
41+
if e.Cause == nil {
42+
return e.Message
43+
}
44+
return e.Message + ": " + e.Cause.Error()
45+
}
46+
47+
func (e ServerError) Is(target error) bool {
48+
_, ok := target.(ServerError)
49+
return ok
50+
}
51+
52+
func (e *ServerError) As(err any) bool {
53+
switch dst := err.(type) {
54+
case *ServerError:
55+
*dst = *e
56+
return true
57+
case **ServerError:
58+
*dst = e
59+
return true
60+
default:
61+
return false
62+
}
63+
}
64+
65+
// Unwrap implements the Go errors.Unwrap interface, enabling
66+
// errors.Unwrap(), errors.Is(), and errors.As() to traverse the
67+
// server error cause chain.
68+
func (e ServerError) Unwrap() error {
69+
return e.Cause
70+
}

pkg/connection/wire_error.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package connection
2+
3+
import "errors"
4+
5+
// wireError is an unexported deserialization target that captures ALL v2+v3 wire fields.
6+
// It is used as the intermediate representation during RPCError.UnmarshalCBOR,
7+
// before populating the v2 public fields on RPCError and creating a ServerError for the v3 view.
8+
type wireError struct {
9+
Code int `json:"code"`
10+
Message string `json:"message,omitempty"`
11+
Description string `json:"description,omitempty"` // SurrealDB v2 only
12+
Kind string `json:"kind,omitempty"` // SurrealDB v3 only
13+
Details any `json:"details,omitempty"` // SurrealDB v3 only
14+
Cause *wireError `json:"cause,omitempty"` // SurrealDB v3 only
15+
}
16+
17+
func (w *wireError) Error() string {
18+
if w == nil {
19+
return "<nil>"
20+
}
21+
return w.Message
22+
}
23+
24+
func (r *wireError) As(err any) bool {
25+
if r == nil {
26+
return false
27+
}
28+
switch e := err.(type) {
29+
case *ServerError:
30+
e.Message = r.Message
31+
e.Kind = r.Kind
32+
e.Code = r.Code
33+
e.Details = r.Details
34+
35+
var cause ServerError
36+
if errors.As(r.Cause, &cause) {
37+
e.Cause = &cause
38+
}
39+
40+
return true
41+
case **ServerError:
42+
se := &ServerError{
43+
Message: r.Message,
44+
Kind: r.Kind,
45+
Code: r.Code,
46+
Details: r.Details,
47+
}
48+
49+
var cause ServerError
50+
if errors.As(r.Cause, &cause) {
51+
se.Cause = &cause
52+
}
53+
54+
*e = se
55+
return true
56+
case *RPCError:
57+
e.Code = r.Code
58+
e.Message = r.Message
59+
e.Description = r.Description
60+
return true
61+
case **RPCError:
62+
rpcErr := &RPCError{
63+
Code: r.Code,
64+
Message: r.Message,
65+
Description: r.Description,
66+
}
67+
*e = rpcErr
68+
return true
69+
}
70+
return false
71+
}

0 commit comments

Comments
 (0)