diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d1dceaca..c8ba119fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,14 @@ Versioning](http://semver.org/spec/v2.0.0.html) except to the first release. ### Changed * Required Go version is `1.24` now (#456). +* Error types redesigned around `errors.Is` / `errors.As` (#469): + `tarantool.Error` renamed to `tarantool.ServerError`; the seven legacy + client error code constants are now package-level `error` sentinels + (`ErrConnectionClosed`, `ErrTimeouted`, ...) with numeric forms exposed + as `tarantool.Code*`; `ClientError` gained a `Cause` field that wraps + the underlying I/O error; `ClientError.Temporary()` removed in favour + of `tarantool.IsRetryable(err)` / `errors.Is(err, ErrRetryable)`. + See MIGRATION.md. * `test_helpers.MockDoer` is now an interface instead of a struct. The `Requests` field became a method `Requests()`. The `NewMockDoer()` constructor now returns the interface and uses a builder pattern. diff --git a/MIGRATION.md b/MIGRATION.md index dd33cd4c8..53b8bd7ef 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -79,6 +79,58 @@ TODO `ModeRW`, `RO` → `ModeRO`, `PreferRW` → `ModePreferRW`, `PreferRO` → `ModePreferRO`, `UnknownRole` → `RoleUnknown`, `MasterRole` → `RoleMaster`, `ReplicaRole` → `RoleReplica`. +* Error types redesigned around Go's `errors.Is` / `errors.As` (#469): + + * `tarantool.Error` (server-side error wrapper) renamed to + `tarantool.ServerError`. + * `ClientError.Code` is now a typed `tarantool.ClientErrorCode` + (underlying `uint32`, same numeric values). + * `ClientError.Temporary()` removed. Use `tarantool.IsRetryable(err)` + or `errors.Is(err, tarantool.ErrRetryable)`. + * The seven legacy `uint32` constants are now package-level `error` + sentinels matched via `errors.Is`. Numeric forms remain available as + `tarantool.Code*` constants: + + | Old (`uint32` constant) | New sentinel | Numeric (`ClientErrorCode`) | + |-------------------------|---------------------------|-----------------------------| + | `ErrConnectionNotReady` | `ErrConnectionNotReady` | `CodeConnectionNotReady` | + | `ErrConnectionClosed` | `ErrConnectionClosed` | `CodeConnectionClosed` | + | `ErrProtocolError` | `ErrProtocolError` | `CodeProtocolError` | + | `ErrTimeouted` | `ErrTimeouted` | `CodeTimeouted` | + | `ErrRateLimited` | `ErrRateLimited` | `CodeRateLimited` | + | `ErrConnectionShutdown` | `ErrConnectionShutdown` | `CodeConnectionShutdown` | + | `ErrIoError` | `ErrIoError` | `CodeIoError` | + + * `ClientError` gained a `Cause error` field; I/O failures wrap their + underlying `net`-layer error and can be inspected with `errors.As`. + * `ClientError.Error()` now formats as + `": : "` (omitting empty parts). + + Before: + ```Go + if clientErr, ok := err.(tarantool.ClientError); ok { + if clientErr.Code == tarantool.ErrConnectionClosed { + // ... + } + if clientErr.Temporary() { + // retry + } + } + var tntErr tarantool.Error + if errors.As(err, &tntErr) { /* ... */ } + ``` + + After: + ```Go + if errors.Is(err, tarantool.ErrConnectionClosed) { + // ... + } + if tarantool.IsRetryable(err) { + // retry + } + var tntErr tarantool.ServerError + if errors.As(err, &tntErr) { /* ... */ } + ``` * `Future.Release()` call could be used to free resources allocated for the `Future` object created by a `Connection` object. * Removed deprecated `NewCall16Request` and `NewCall17Request` constructors. diff --git a/arrow/tarantool_test.go b/arrow/tarantool_test.go index f99ef73dc..0b274e770 100644 --- a/arrow/tarantool_test.go +++ b/arrow/tarantool_test.go @@ -76,7 +76,7 @@ func TestInsert_invalid(t *testing.T) { req := arrow.NewInsertRequest(space, arr) _, err = conn.Do(req).Get() - ttErr := err.(tarantool.Error) + ttErr := err.(tarantool.ServerError) require.Contains(t, a.expected, ttErr.Code) }) diff --git a/box/tarantool_test.go b/box/tarantool_test.go index 0f5e2b336..25d9caacf 100644 --- a/box/tarantool_test.go +++ b/box/tarantool_test.go @@ -153,7 +153,7 @@ func TestBox_Sugar_Schema_UserCreate_AlreadyExists(t *testing.T) { require.Error(t, err) // Require that error code is ER_USER_EXISTS. - var boxErr tarantool.Error + var boxErr tarantool.ServerError errors.As(err, &boxErr) require.Equal(t, iproto.ER_USER_EXISTS, boxErr.Code) } @@ -295,7 +295,7 @@ func TestBox_Sugar_Schema_UserDrop_UnknownUser(t *testing.T) { err = b.Schema().User().Drop(ctx, "some_strange_not_existing_name", box.UserDropOptions{}) require.Error(t, err) - var boxErr tarantool.Error + var boxErr tarantool.ServerError // Require that error code is ER_NO_SUCH_USER errors.As(err, &boxErr) @@ -313,7 +313,7 @@ func TestSchemaUser_Passwd_NotFound(t *testing.T) { err = b.Schema().User().Passwd(ctx, "not-exists-passwd", "new_password") require.Error(t, err) // Require that error code is ER_USER_EXISTS. - var boxErr tarantool.Error + var boxErr tarantool.ServerError errors.As(err, &boxErr) require.Equal(t, iproto.ER_NO_SUCH_USER, boxErr.Code) } @@ -391,7 +391,7 @@ func TestSchemaUser_Passwd_WithoutGrants(t *testing.T) { require.Error(t, err) // Require that error code is AccessDeniedError, - var boxErr tarantool.Error + var boxErr tarantool.ServerError errors.As(err, &boxErr) require.Equal(t, iproto.ER_ACCESS_DENIED, boxErr.Code) } @@ -456,7 +456,7 @@ func TestBox_Sugar_Schema_UserGrant_NoSu(t *testing.T) { require.Error(t, err) // Require that error code is ER_ACCESS_DENIED. - var boxErr tarantool.Error + var boxErr tarantool.ServerError errors.As(err, &boxErr) require.Equal(t, iproto.ER_ACCESS_DENIED, boxErr.Code) } @@ -546,7 +546,7 @@ func TestSchemaUser_Revoke_WithoutSu(t *testing.T) { require.Error(t, err) // Require that error code is ER_ACCESS_DENIED. - var boxErr tarantool.Error + var boxErr tarantool.ServerError errors.As(err, &boxErr) require.Equal(t, iproto.ER_ACCESS_DENIED, boxErr.Code) } diff --git a/connection.go b/connection.go index 328bab4cc..db332c804 100644 --- a/connection.go +++ b/connection.go @@ -380,7 +380,7 @@ func (conn *Connection) ClosedNow() bool { // Close closes Connection. // After this method called, there is no way to reopen this Connection. func (conn *Connection) Close() error { - err := ClientError{ErrConnectionClosed, "connection closed by client"} + err := newClientError(CodeConnectionClosed, "connection closed by client", nil) conn.mutex.Lock() defer conn.mutex.Unlock() return conn.closeConnection(err, true) @@ -552,7 +552,7 @@ func (conn *Connection) connect(ctx context.Context) error { } } if conn.state == connClosed { - err = ClientError{ErrConnectionClosed, "using closed connection"} + err = newClientError(CodeConnectionClosed, "using closed connection", nil) } return err } @@ -631,8 +631,7 @@ func (conn *Connection) runReconnects(ctx context.Context) error { if ctx.Err() != nil { return err } - if clientErr, ok := err.(ClientError); ok && - clientErr.Code == ErrConnectionClosed { + if errors.Is(err, ErrConnectionClosed) { return err } } else { @@ -664,7 +663,7 @@ func (conn *Connection) runReconnects(ctx context.Context) error { slog.String(LogKeyAddress, conn.addrString()), ) // mark connection as closed to avoid reopening by another goroutine - return ClientError{ErrConnectionClosed, "last reconnect failed"} + return newClientError(CodeConnectionClosed, "last reconnect failed", nil) } func (conn *Connection) reconnectImpl(neterr error, c Conn) { @@ -737,11 +736,11 @@ func (conn *Connection) writer(w writeFlusher, c Conn) { runtime.Gosched() if len(conn.dirtyShard) == 0 { if err := w.Flush(); err != nil { - err = ClientError{ - ErrIoError, - fmt.Sprintf("failed to flush data to the connection: %s", err), - } - conn.reconnect(err, c) + conn.reconnect(newClientError( + CodeIoError, + "failed to flush data to the connection", + err, + ), c) return } } @@ -764,11 +763,11 @@ func (conn *Connection) writer(w writeFlusher, c Conn) { continue } if _, err := w.Write(packet.b); err != nil { - err = ClientError{ - ErrIoError, - fmt.Sprintf("failed to write data to the connection: %s", err), - } - conn.reconnect(err, c) + conn.reconnect(newClientError( + CodeIoError, + "failed to write data to the connection", + err, + ), c) return } packet.Reset() @@ -861,23 +860,23 @@ func (conn *Connection) reader(r io.Reader, c Conn) { buf, err = read(r, conn.lenbuf[:], conn.alloc) if err != nil { - err = ClientError{ - ErrIoError, - fmt.Sprintf("failed to read data from the connection: %s", err), - } - conn.reconnect(err, c) + conn.reconnect(newClientError( + CodeIoError, + "failed to read data from the connection", + err, + ), c) return } header, code, err := decodeHeader(conn.dec, &buf) if err != nil { - err = ClientError{ - ErrProtocolError, - fmt.Sprintf("failed to decode IPROTO header: %s", err), - } buf.Release() - conn.reconnect(err, c) + conn.reconnect(newClientError( + CodeProtocolError, + "failed to decode IPROTO header", + err, + ), c) return } @@ -886,12 +885,13 @@ func (conn *Connection) reader(r io.Reader, c Conn) { if event, err := readWatchEvent(&buf); err == nil { events <- event } else { - err = ClientError{ - ErrProtocolError, - fmt.Sprintf("failed to decode IPROTO_EVENT: %s", err), - } + wrapped := newClientError( + CodeProtocolError, + "failed to decode IPROTO_EVENT", + err, + ) conn.logger.Warn(LogMsgWatchEventReadFailed, - slog.Any(LogKeyError, err), + slog.Any(LogKeyError, wrapped), ) } buf.Release() @@ -938,10 +938,11 @@ func (conn *Connection) newFuture(req Request) *future { select { case conn.rlimit <- struct{}{}: default: - fut.err = ClientError{ - ErrRateLimited, - "Request is rate limited on client", - } + fut.err = newClientError( + CodeRateLimited, + "request is rate limited on client", + nil, + ) fut.finish() return fut } @@ -952,26 +953,17 @@ func (conn *Connection) newFuture(req Request) *future { shard.rmut.Lock() switch atomic.LoadUint32(&conn.state) { case connClosed: - fut.err = ClientError{ - ErrConnectionClosed, - "using closed connection", - } + fut.err = newClientError(CodeConnectionClosed, "using closed connection", nil) fut.finish() shard.rmut.Unlock() return fut case connDisconnected: - fut.err = ClientError{ - ErrConnectionNotReady, - "client connection is not ready", - } + fut.err = newClientError(CodeConnectionNotReady, "client connection is not ready", nil) fut.finish() shard.rmut.Unlock() return fut case connShutdown: - fut.err = ClientError{ - ErrConnectionShutdown, - "server shutdown in progress", - } + fut.err = newClientError(CodeConnectionShutdown, "server shutdown in progress", nil) fut.finish() shard.rmut.Unlock() return fut @@ -1174,10 +1166,11 @@ func (conn *Connection) timeouts() { } else { fut.next = nil } - fut.setError(ClientError{ - Code: ErrTimeouted, - Msg: fmt.Sprintf("client timeout for request %d", fut.requestId), - }) + fut.setError(newClientError( + CodeTimeouted, + fmt.Sprintf("client timeout for request %d", fut.requestId), + nil, + )) conn.markDone() shard.bufmut.Unlock() } @@ -1562,7 +1555,7 @@ func (conn *Connection) shutdown(forever bool) error { if !atomic.CompareAndSwapUint32(&conn.state, connConnected, connShutdown) { if forever { - err := ClientError{ErrConnectionClosed, "connection closed by client"} + err := newClientError(CodeConnectionClosed, "connection closed by client", nil) return conn.closeConnection(err, true) } return nil @@ -1593,7 +1586,7 @@ func (conn *Connection) shutdown(forever bool) error { } if forever { - err := ClientError{ErrConnectionClosed, "connection closed by client"} + err := newClientError(CodeConnectionClosed, "connection closed by client", nil) return conn.closeConnection(err, true) } else { // Start to reconnect based on common rules, same as in net.box. @@ -1601,10 +1594,11 @@ func (conn *Connection) shutdown(forever bool) error { // subscribed connections are terminated. // See https://www.tarantool.io/en/doc/latest/dev_guide/internals/iproto/graceful_shutdown/ // step 3. - conn.reconnectImpl(ClientError{ - ErrConnectionClosed, + conn.reconnectImpl(newClientError( + CodeConnectionClosed, "connection closed after server shutdown", - }, conn.c) + nil, + ), conn.c) return nil } } diff --git a/dial.go b/dial.go index a89b94e54..2074ce864 100644 --- a/dial.go +++ b/dial.go @@ -642,13 +642,12 @@ func readResponse(ctx context.Context, conn Conn, req Request) (Response, error) _, err = resp.Decode() if err != nil { - switch err.(type) { - case Error: + var serverErr ServerError + if errors.As(err, &serverErr) { return resp, err - default: - resp.Release() - return nil, fmt.Errorf("decode response body error: %w", err) } + resp.Release() + return nil, fmt.Errorf("decode response body error: %w", err) } return resp, nil diff --git a/errors.go b/errors.go index c5eeb2975..16a32b89e 100644 --- a/errors.go +++ b/errors.go @@ -1,64 +1,141 @@ package tarantool import ( + "errors" "fmt" + "strings" "github.com/tarantool/go-iproto" ) -// Error is wrapper around error returned by Tarantool. -type Error struct { - Code iproto.Error - Msg string - ExtendedInfo *BoxError +// ClientErrorCode is the numeric identifier for a client-side error. +// Values are stable across Tarantool client implementations. +type ClientErrorCode uint32 + +// Client error codes. The numeric values match the legacy uint32 +// constants exposed by previous versions of go-tarantool. +const ( + CodeConnectionNotReady ClientErrorCode = 0x4000 + iota + CodeConnectionClosed + CodeProtocolError + CodeTimeouted + CodeRateLimited + CodeConnectionShutdown + CodeIoError +) + +// sentinelError is a package-level error value matched via errors.Is. +type sentinelError struct { + code ClientErrorCode + msg string } -// Error converts an Error to a string. -func (tnterr Error) Error() string { - if tnterr.ExtendedInfo != nil { - return tnterr.ExtendedInfo.Error() - } +func (s *sentinelError) Error() string { return s.msg } +func (s *sentinelError) Code() ClientErrorCode { return s.code } + +// Sentinel errors for client-side failure modes. Compare with errors.Is. +var ( + ErrConnectionNotReady error = &sentinelError{CodeConnectionNotReady, "connection not ready"} + ErrConnectionClosed error = &sentinelError{CodeConnectionClosed, "connection closed"} + ErrProtocolError error = &sentinelError{CodeProtocolError, "protocol error"} + ErrTimeouted error = &sentinelError{CodeTimeouted, "request timed out"} + ErrRateLimited error = &sentinelError{CodeRateLimited, "rate limited"} + ErrConnectionShutdown error = &sentinelError{CodeConnectionShutdown, "connection shutdown"} + ErrIoError error = &sentinelError{CodeIoError, "I/O error"} - return fmt.Sprintf("%s (0x%x)", tnterr.Msg, tnterr.Code) + // ErrRetryable marks errors that may succeed on retry. It is + // joined into the error chain of any retryable ClientError so + // that errors.Is(err, ErrRetryable) returns true. + ErrRetryable = errors.New("retryable") +) + +// codeToSentinel maps a code to its package-level sentinel. +var codeToSentinel = map[ClientErrorCode]error{ + CodeConnectionNotReady: ErrConnectionNotReady, + CodeConnectionClosed: ErrConnectionClosed, + CodeProtocolError: ErrProtocolError, + CodeTimeouted: ErrTimeouted, + CodeRateLimited: ErrRateLimited, + CodeConnectionShutdown: ErrConnectionShutdown, + CodeIoError: ErrIoError, } -// ClientError is connection error produced by this client, -// i.e. connection failures or timeouts. +// retryableSentinels lists the codes whose chain implies ErrRetryable. +var retryableSentinels = map[ClientErrorCode]struct{}{ + CodeConnectionNotReady: {}, + CodeTimeouted: {}, + CodeRateLimited: {}, + CodeIoError: {}, +} + +// ClientError is a failure produced by this client: connection state +// transitions, request timeouts, protocol decoding, or I/O. +// +// Compare with package sentinels via errors.Is. If Cause is set, it +// is reachable via errors.As / errors.Unwrap. type ClientError struct { - Code uint32 - Msg string + Code ClientErrorCode + Msg string + Cause error } -// Error converts a ClientError to a string. -func (clierr ClientError) Error() string { - return fmt.Sprintf("%s (0x%x)", clierr.Msg, clierr.Code) +// Error formats as ": : ", omitting any empty +// segment. If the code has no registered sentinel, the prefix falls +// back to "client error 0x". +func (e ClientError) Error() string { + parts := make([]string, 0, 3) + if s, ok := codeToSentinel[e.Code]; ok { + parts = append(parts, s.Error()) + } else { + parts = append(parts, fmt.Sprintf("client error 0x%x", uint32(e.Code))) + } + if e.Msg != "" && parts[0] != e.Msg { + parts = append(parts, e.Msg) + } + if e.Cause != nil { + parts = append(parts, e.Cause.Error()) + } + return strings.Join(parts, ": ") } -// Temporary returns true if next attempt to perform request may succeeded. -// -// Currently it returns true when: -// -// - Connection is not connected at the moment -// -// - request is timeouted -// -// - request is aborted due to rate limit. -func (clierr ClientError) Temporary() bool { - switch clierr.Code { - case ErrConnectionNotReady, ErrTimeouted, ErrRateLimited, ErrIoError: - return true - default: - return false +// Unwrap exposes the sentinel, ErrRetryable (when applicable), and +// the underlying cause to errors.Is / errors.As. +func (e ClientError) Unwrap() []error { + out := make([]error, 0, 3) + if s, ok := codeToSentinel[e.Code]; ok { + out = append(out, s) + } + if _, ok := retryableSentinels[e.Code]; ok { + out = append(out, ErrRetryable) + } + if e.Cause != nil { + out = append(out, e.Cause) } + return out } -// Tarantool client error codes. -const ( - ErrConnectionNotReady = 0x4000 + iota - ErrConnectionClosed = 0x4000 + iota - ErrProtocolError = 0x4000 + iota - ErrTimeouted = 0x4000 + iota - ErrRateLimited = 0x4000 + iota - ErrConnectionShutdown = 0x4000 + iota - ErrIoError = 0x4000 + iota -) +// newClientError is the internal constructor used across the package. +func newClientError(code ClientErrorCode, msg string, cause error) ClientError { + return ClientError{Code: code, Msg: msg, Cause: cause} +} + +// ServerError wraps an error returned by the Tarantool server. +type ServerError struct { + Code iproto.Error + Msg string + ExtendedInfo *BoxError +} + +// Error converts a ServerError to a string. +func (e ServerError) Error() string { + if e.ExtendedInfo != nil { + return e.ExtendedInfo.Error() + } + return fmt.Sprintf("%s (0x%x)", e.Msg, int(e.Code)) +} + +// IsRetryable reports whether err indicates a transient failure that +// may succeed on retry. +func IsRetryable(err error) bool { + return errors.Is(err, ErrRetryable) +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 000000000..e08c77be0 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,124 @@ +package tarantool + +import ( + "errors" + "net" + "testing" + + "github.com/tarantool/go-iproto" +) + +func TestClientError_IsSentinel(t *testing.T) { + cases := []struct { + code ClientErrorCode + sentinel error + }{ + {CodeConnectionNotReady, ErrConnectionNotReady}, + {CodeConnectionClosed, ErrConnectionClosed}, + {CodeProtocolError, ErrProtocolError}, + {CodeTimeouted, ErrTimeouted}, + {CodeRateLimited, ErrRateLimited}, + {CodeConnectionShutdown, ErrConnectionShutdown}, + {CodeIoError, ErrIoError}, + } + for _, tc := range cases { + err := newClientError(tc.code, "x", nil) + if !errors.Is(err, tc.sentinel) { + t.Errorf("code 0x%x: errors.Is did not match its sentinel", uint32(tc.code)) + } + } +} + +func TestClientError_IsRetryable(t *testing.T) { + retryable := []ClientErrorCode{ + CodeConnectionNotReady, CodeTimeouted, CodeRateLimited, CodeIoError, + } + notRetryable := []ClientErrorCode{ + CodeConnectionClosed, CodeProtocolError, CodeConnectionShutdown, + } + for _, code := range retryable { + err := newClientError(code, "x", nil) + if !IsRetryable(err) { + t.Errorf("code 0x%x should be retryable", uint32(code)) + } + if !errors.Is(err, ErrRetryable) { + t.Errorf("code 0x%x: errors.Is(_, ErrRetryable) failed", uint32(code)) + } + } + for _, code := range notRetryable { + err := newClientError(code, "x", nil) + if IsRetryable(err) { + t.Errorf("code 0x%x should NOT be retryable", uint32(code)) + } + } +} + +func TestClientError_WrapsCause(t *testing.T) { + cause := &net.OpError{Op: "read", Err: errors.New("eof")} + err := newClientError(CodeIoError, "failed to read", cause) + + var got *net.OpError + if !errors.As(err, &got) { + t.Fatal("errors.As did not unwrap to *net.OpError") + } + if got != cause { + t.Fatal("errors.As returned a different *net.OpError instance") + } + if !errors.Is(err, ErrIoError) { + t.Fatal("errors.Is did not match ErrIoError after wrap") + } + if !errors.Is(err, ErrRetryable) { + t.Fatal("I/O error should also imply ErrRetryable") + } +} + +func TestClientError_ErrorString(t *testing.T) { + cases := []struct { + err ClientError + want string + }{ + { + newClientError(CodeConnectionShutdown, "server shutdown in progress", nil), + "connection shutdown: server shutdown in progress", + }, + { + newClientError(CodeIoError, "failed to read", errors.New("eof")), + "I/O error: failed to read: eof", + }, + { + newClientError(CodeTimeouted, "", nil), + "request timed out", + }, + { + ClientError{Code: ClientErrorCode(0x9999), Msg: "weird"}, + "client error 0x9999: weird", + }, + } + for _, tc := range cases { + if got := tc.err.Error(); got != tc.want { + t.Errorf("Error() = %q, want %q", got, tc.want) + } + } +} + +func TestServerError_AsAndFormat(t *testing.T) { + se := ServerError{Code: iproto.ER_TUPLE_FOUND, Msg: "Duplicate key exists"} + + var got ServerError + if !errors.As(se, &got) { + t.Fatal("errors.As did not match ServerError") + } + if got.Code != iproto.ER_TUPLE_FOUND { + t.Fatalf("Code = %v", got.Code) + } + if want := "Duplicate key exists (0x3)"; se.Error() != want { + t.Errorf("Error() = %q, want %q", se.Error(), want) + } +} + +func TestSentinelError_DoesNotMatchOtherSentinel(t *testing.T) { + err := newClientError(CodeTimeouted, "x", nil) + if errors.Is(err, ErrConnectionClosed) { + t.Error("ErrTimeouted matched ErrConnectionClosed") + } +} diff --git a/example_test.go b/example_test.go index 3ff45fcb0..2c1a62c93 100644 --- a/example_test.go +++ b/example_test.go @@ -2,6 +2,7 @@ package tarantool_test import ( "context" + "errors" "fmt" "log/slog" "net" @@ -1375,7 +1376,10 @@ func ExampleConnection_Do_failure() { // fmt.Printf("Failed to execute the request: %s\n", err) if resp == nil { // Something happens in a client process (timeout, IO error etc). - fmt.Printf("Resp == nil, ClientErr = %s\n", err.(tarantool.ClientError)) + var clientErr tarantool.ClientError + if errors.As(err, &clientErr) { + fmt.Printf("Resp == nil, ClientErr = %s\n", clientErr) + } } else { // Response exist. So it could be a Tarantool error or a decode // error. We need to check the error code. @@ -1383,9 +1387,11 @@ func ExampleConnection_Do_failure() { if resp.Header().Error == tarantool.ErrorNo { fmt.Printf("Decode error: %s\n", err) } else { - code := err.(tarantool.Error).Code - fmt.Printf("Error code from the error: %d\n", code) - fmt.Printf("Error short from the error: %s\n", code) + var serverErr tarantool.ServerError + if errors.As(err, &serverErr) { + fmt.Printf("Error code from the error: %d\n", serverErr.Code) + fmt.Printf("Error short from the error: %s\n", serverErr.Code) + } } } } @@ -1523,7 +1529,7 @@ func ExampleConnection_CloseGraceful_force() { // Force Connection.Close()! // Connection.CloseGraceful() done! // Result: - // [] connection closed by client (0x4001) + // [] connection closed: connection closed by client } func ExampleWatchOnceRequest() { diff --git a/pool/example_test.go b/pool/example_test.go index 1dade420f..cf9db3b41 100644 --- a/pool/example_test.go +++ b/pool/example_test.go @@ -479,7 +479,7 @@ func ExamplePool_CloseGraceful_force() { // Force Pool.Close()! // Pool.CloseGraceful() done! // Result: - // [] connection closed by client (0x4001) + // [] connection closed: connection closed by client } func ExampleConnect_invalidOpts() { diff --git a/response.go b/response.go index 4ec9a536d..1c53dbd54 100644 --- a/response.go +++ b/response.go @@ -384,7 +384,7 @@ func (resp *baseResponse) Decode() ([]any, error) { } if info.decodedError != "" { - resp.err = Error{resp.header.Error, info.decodedError, + resp.err = ServerError{resp.header.Error, info.decodedError, info.errorExtendedInfo} } } @@ -447,7 +447,7 @@ func (resp *SelectResponse) Decode() ([]any, error) { } if info.decodedError != "" { - resp.err = Error{resp.header.Error, info.decodedError, + resp.err = ServerError{resp.header.Error, info.decodedError, info.errorExtendedInfo} } } @@ -515,7 +515,7 @@ func (resp *ExecuteResponse) Decode() ([]any, error) { } if info.decodedError != "" { - resp.err = Error{resp.header.Error, info.decodedError, + resp.err = ServerError{resp.header.Error, info.decodedError, info.errorExtendedInfo} } } @@ -580,7 +580,7 @@ func (resp *baseResponse) DecodeTyped(res any) error { } } if info.decodedError != "" { - err = Error{resp.header.Error, info.decodedError, info.errorExtendedInfo} + err = ServerError{resp.header.Error, info.decodedError, info.errorExtendedInfo} } } return err @@ -628,7 +628,7 @@ func (resp *SelectResponse) DecodeTyped(res any) error { } } if info.decodedError != "" { - err = Error{resp.header.Error, info.decodedError, info.errorExtendedInfo} + err = ServerError{resp.header.Error, info.decodedError, info.errorExtendedInfo} } } return err @@ -679,7 +679,7 @@ func (resp *ExecuteResponse) DecodeTyped(res any) error { } } if info.decodedError != "" { - err = Error{resp.header.Error, info.decodedError, info.errorExtendedInfo} + err = ServerError{resp.header.Error, info.decodedError, info.errorExtendedInfo} } } return err diff --git a/shutdown_test.go b/shutdown_test.go index 4708a119e..6725bdbb8 100644 --- a/shutdown_test.go +++ b/shutdown_test.go @@ -4,6 +4,7 @@ package tarantool_test import ( + "errors" "sync" "syscall" "testing" @@ -92,8 +93,8 @@ func testGracefulShutdown(t *testing.T, conn *Connection, inst *test_helpers.Tar require.Eventually(t, func() bool { _, err := conn.Do(NewPingRequest()).Get() - return err != nil && err.Error() == "server shutdown in progress (0x4005)" - }, timeout, tick, "expected shutdown error 'server shutdown in progress (0x4005)'") + return err != nil && errors.Is(err, ErrConnectionShutdown) + }, timeout, tick, "expected shutdown error matching ErrConnectionShutdown") // Check that requests started before the shutdown finish successfully. data, err := fut.Get() diff --git a/tarantool_test.go b/tarantool_test.go index 56dabf6ea..a88c773d1 100644 --- a/tarantool_test.go +++ b/tarantool_test.go @@ -679,7 +679,7 @@ func TestClient(t *testing.T) { } req = NewInsertRequest(spaceNo).Tuple(&Tuple{Id: 1, Msg: "hello", Name: "world"}) data, err = conn.Do(req).Get() - tntErr, ok := err.(Error) + tntErr, ok := err.(ServerError) require.True(t, ok, "Expected Error type") assert.Equal(t, iproto.ER_TUPLE_FOUND, tntErr.Code, "Expected %s but got: %v", iproto.ER_TUPLE_FOUND, err) @@ -1198,7 +1198,7 @@ func TestStressSQL(t *testing.T) { _, err = resp.Decode() require.Error(t, err, "Expected error while decoding") - tntErr, ok := err.(Error) + tntErr, ok := err.(ServerError) assert.True(t, ok) assert.Equal(t, iproto.ER_SPACE_EXISTS, tntErr.Code) require.Equal(t, iproto.ER_SPACE_EXISTS, resp.Header().Error, "Unexpected response error") @@ -1998,10 +1998,11 @@ func TestComparableErrorsCancelCauseContext(t *testing.T) { ctxCause, cancelCause := context.WithCancelCause(context.Background()) req := NewPingRequest().Context(ctxCause) - cancelCause(ClientError{ErrConnectionClosed, "something went wrong"}) + cancelCause(ClientError{Code: CodeConnectionClosed, Msg: "something went wrong"}) _, err := conn.Do(req).Get() var tmpErr ClientError require.ErrorAs(t, err, &tmpErr) + require.ErrorIs(t, err, ErrConnectionClosed) } // waitCtxRequest waits for the WaitGroup in Body() call and returns @@ -2633,7 +2634,7 @@ func TestErrorExtendedInfoBasic(t *testing.T) { _, err := conn.Do(NewEvalRequest("not a Lua code").Args([]any{})).Get() require.Errorf(t, err, "expected error on invalid Lua code") - ttErr, ok := err.(Error) + ttErr, ok := err.(ServerError) require.Truef(t, ok, "error is built from a Tarantool error") expected := BoxError{ @@ -2661,7 +2662,7 @@ func TestErrorExtendedInfoStack(t *testing.T) { _, err := conn.Do(NewEvalRequest("error(chained_error)").Args([]any{})).Get() require.Errorf(t, err, "expected error on explicit error raise") - ttErr, ok := err.(Error) + ttErr, ok := err.(ServerError) require.Truef(t, ok, "error is built from a Tarantool error") expected := BoxError{ @@ -2697,7 +2698,7 @@ func TestErrorExtendedInfoFields(t *testing.T) { _, err := conn.Do(NewEvalRequest("error(access_denied_error)").Args([]any{})).Get() require.Errorf(t, err, "expected error on forbidden action") - ttErr, ok := err.(Error) + ttErr, ok := err.(ServerError) require.Truef(t, ok, "error is built from a Tarantool error") expected := BoxError{