Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions apierror/apierror.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@ var (
ErrQueryNotFound = errors.New("query not found")
)

// Canonical conflict sentinels for HTTP 409 responses. The API disambiguates
// the cause via ApplicationError.code. Packages that assign these variables
// (rather than defining their own) share the same sentinel instances so
// errors.Is works across those packages.
var (
// ErrChannelAlreadyExists is returned when a channel with the same name already exists.
ErrChannelAlreadyExists = errors.New("channel already exists")
// ErrWalletAlreadyExists is returned when a wallet with the same name already exists.
ErrWalletAlreadyExists = errors.New("wallet already exists")
// ErrWatcherAlreadyExists is returned when a watcher with the same name already exists.
ErrWatcherAlreadyExists = errors.New("watcher already exists")
// ErrIdempotencyKeyMismatch is returned when an idempotency key is reused with a
// different canonical request body.
ErrIdempotencyKeyMismatch = errors.New("idempotency key reused with different request")
// ErrOperationNotFinalizable is returned when a draft operation cannot be finalized
// in its current state.
ErrOperationNotFinalizable = errors.New("operation not finalizable")
// ErrOperationNotCancellable is returned when a draft operation cannot be cancelled
// in its current state.
ErrOperationNotCancellable = errors.New("operation not cancellable")
// ErrOperationDeadlineElapsed is returned when an operation's deadline has elapsed.
ErrOperationDeadlineElapsed = errors.New("operation deadline elapsed")
// ErrWalletAlreadyArchived is returned when a wallet is already archived.
ErrWalletAlreadyArchived = errors.New("wallet already archived")
// ErrChainUnavailable is returned when the chain infrastructure required to
// create a wallet is not available (e.g. deployment infrastructure is in a
// failed state). This is a rare operational case that typically requires
// support to resolve.
ErrChainUnavailable = errors.New("chain unavailable for wallet creation")
)

// ErrUnexpectedStatusCode is returned when the API responds with an HTTP status
// the SDK does not handle explicitly.
var ErrUnexpectedStatusCode = errors.New("unexpected status code")
Expand Down Expand Up @@ -139,6 +170,66 @@ func NotFoundCode(appErr *apiClient.ApplicationError) string {
return string(*appErr.Code)
}

// Conflict maps a 409 ApplicationError to its canonical conflict sentinel based
// on ApplicationError.code, or returns nil when the code is missing or
// unrecognized (forward-compatible for codes added after this SDK release).
func Conflict(appErr *apiClient.ApplicationError) error {
if appErr == nil || appErr.Code == nil {
return nil
}

switch *appErr.Code {
case apiClient.ApplicationErrorCodeChannelAlreadyExists:
return ErrChannelAlreadyExists
case apiClient.ApplicationErrorCodeWalletAlreadyExists:
return ErrWalletAlreadyExists
case apiClient.ApplicationErrorCodeWatcherAlreadyExists:
return ErrWatcherAlreadyExists
case apiClient.ApplicationErrorCodeIdempotencyKeyMismatch:
return ErrIdempotencyKeyMismatch
case apiClient.ApplicationErrorCodeOperationNotFinalizable:
return ErrOperationNotFinalizable
case apiClient.ApplicationErrorCodeOperationNotCancellable:
return ErrOperationNotCancellable
case apiClient.ApplicationErrorCodeOperationDeadlineElapsed:
return ErrOperationDeadlineElapsed
case apiClient.ApplicationErrorCodeWalletAlreadyArchived:
return ErrWalletAlreadyArchived
case apiClient.ApplicationErrorCodeChainUnavailable:
return ErrChainUnavailable
default:
return nil
}
}

// WrapConflict resolves a 409 ApplicationError to its canonical conflict
// sentinel and wraps it with opErr when ApplicationError.code is recognized.
// When the code is missing or unknown, it returns only opErr so callers are not
// given a wrong typed sentinel. detail is appended when the server message is
// absent.
func WrapConflict(appErr *apiClient.ApplicationError, opErr error, detail string) error {
msg := notFoundMessage(appErr, detail)

if mapped := Conflict(appErr); mapped != nil {
if msg != "" {
return fmt.Errorf("%w: %w: %s", opErr, mapped, msg)
}
return fmt.Errorf("%w: %w", opErr, mapped)
}
if msg != "" {
return fmt.Errorf("%w: %s", opErr, msg)
}
return opErr
}

// ConflictCode returns ApplicationError.code as a string, or empty when absent.
func ConflictCode(appErr *apiClient.ApplicationError) string {
if appErr == nil || appErr.Code == nil {
return ""
}
return string(*appErr.Code)
}

// NotFoundWarnMessage builds a log message for a 404 response. When
// ApplicationError.code is recognized, the message names that resource; otherwise
// it uses expectedFallback when provided (for single-cause endpoints), or a
Expand Down
96 changes: 95 additions & 1 deletion apierror/apierror_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import (
"net/http"
"testing"

apiClient "github.com/smartcontractkit/crec-api-go/client"
"github.com/stretchr/testify/assert"

apiClient "github.com/smartcontractkit/crec-api-go/client"

"github.com/smartcontractkit/crec-sdk/apierror"
)

Expand Down Expand Up @@ -224,3 +225,96 @@ func TestApierror_Wrap(t *testing.T) {
assert.ErrorIs(t, err, apierror.ErrUnexpectedStatusCode)
})
}

func TestApierror_Conflict(t *testing.T) {
channelCode := apiClient.ApplicationErrorCodeChannelAlreadyExists
walletCode := apiClient.ApplicationErrorCodeWalletAlreadyExists
watcherCode := apiClient.ApplicationErrorCodeWatcherAlreadyExists
idempotencyCode := apiClient.ApplicationErrorCodeIdempotencyKeyMismatch
notFinalizableCode := apiClient.ApplicationErrorCodeOperationNotFinalizable
notCancellableCode := apiClient.ApplicationErrorCodeOperationNotCancellable
deadlineCode := apiClient.ApplicationErrorCodeOperationDeadlineElapsed
archivedCode := apiClient.ApplicationErrorCodeWalletAlreadyArchived
chainUnavailableCode := apiClient.ApplicationErrorCodeChainUnavailable
futureCode := apiClient.ApplicationErrorCode("SOME_FUTURE_CONFLICT")

tests := []struct {
name string
appErr *apiClient.ApplicationError
wantErr error
}{
{name: "nil application error returns nil", appErr: nil, wantErr: nil},
{name: "nil code returns nil", appErr: &apiClient.ApplicationError{Type: apiClient.CONFLICT}, wantErr: nil},
{name: "channel", appErr: &apiClient.ApplicationError{Code: &channelCode}, wantErr: apierror.ErrChannelAlreadyExists},
{name: "wallet", appErr: &apiClient.ApplicationError{Code: &walletCode}, wantErr: apierror.ErrWalletAlreadyExists},
{name: "watcher", appErr: &apiClient.ApplicationError{Code: &watcherCode}, wantErr: apierror.ErrWatcherAlreadyExists},
{name: "idempotency", appErr: &apiClient.ApplicationError{Code: &idempotencyCode}, wantErr: apierror.ErrIdempotencyKeyMismatch},
{name: "not finalizable", appErr: &apiClient.ApplicationError{Code: &notFinalizableCode}, wantErr: apierror.ErrOperationNotFinalizable},
{name: "not cancellable", appErr: &apiClient.ApplicationError{Code: &notCancellableCode}, wantErr: apierror.ErrOperationNotCancellable},
{name: "deadline elapsed", appErr: &apiClient.ApplicationError{Code: &deadlineCode}, wantErr: apierror.ErrOperationDeadlineElapsed},
{name: "already archived", appErr: &apiClient.ApplicationError{Code: &archivedCode}, wantErr: apierror.ErrWalletAlreadyArchived},
{name: "chain unavailable", appErr: &apiClient.ApplicationError{Code: &chainUnavailableCode}, wantErr: apierror.ErrChainUnavailable},
{name: "unknown future code degrades to nil", appErr: &apiClient.ApplicationError{Code: &futureCode}, wantErr: nil},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := apierror.Conflict(tt.appErr)

if tt.wantErr == nil {
assert.NoError(t, got)
return
}
assert.ErrorIs(t, got, tt.wantErr)
})
}
}

func TestApierror_WrapConflict(t *testing.T) {
opErr := errors.New("failed to create channel")
channelCode := apiClient.ApplicationErrorCodeChannelAlreadyExists
futureCode := apiClient.ApplicationErrorCode("SOME_FUTURE_CONFLICT")
detail := "name my-channel"

t.Run("recognized code wraps opErr with typed sentinel and server message", func(t *testing.T) {
appErr := &apiClient.ApplicationError{Code: &channelCode, Message: "channel already exists"}
err := apierror.WrapConflict(appErr, opErr, detail)

assert.ErrorIs(t, err, opErr)
assert.ErrorIs(t, err, apierror.ErrChannelAlreadyExists)
assert.NotErrorIs(t, err, apierror.ErrWalletAlreadyExists)
assert.Contains(t, err.Error(), "channel already exists")
assert.NotContains(t, err.Error(), detail)
})

t.Run("missing code returns only opErr with server message", func(t *testing.T) {
appErr := &apiClient.ApplicationError{Type: apiClient.CONFLICT, Message: "conflict"}
err := apierror.WrapConflict(appErr, opErr, detail)

assert.ErrorIs(t, err, opErr)
assert.NotErrorIs(t, err, apierror.ErrChannelAlreadyExists)
assert.Contains(t, err.Error(), "conflict")
})

t.Run("unknown future code returns only opErr with detail", func(t *testing.T) {
appErr := &apiClient.ApplicationError{Code: &futureCode}
err := apierror.WrapConflict(appErr, opErr, detail)

assert.ErrorIs(t, err, opErr)
assert.NotErrorIs(t, err, apierror.ErrChannelAlreadyExists)
assert.Contains(t, err.Error(), detail)
})

t.Run("nil application error uses detail without panicking", func(t *testing.T) {
err := apierror.WrapConflict(nil, opErr, detail)

assert.ErrorIs(t, err, opErr)
assert.Contains(t, err.Error(), detail)
})
}

func TestApierror_ConflictCode(t *testing.T) {
channelCode := apiClient.ApplicationErrorCodeChannelAlreadyExists
assert.Equal(t, "", apierror.ConflictCode(nil))
assert.Equal(t, "CHANNEL_ALREADY_EXISTS", apierror.ConflictCode(&apiClient.ApplicationError{Code: &channelCode}))
}
12 changes: 12 additions & 0 deletions channels/channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const (
var (
// ErrChannelNotFound is returned when a channel is not found (404 response)
ErrChannelNotFound = apierror.ErrChannelNotFound
// ErrChannelAlreadyExists is returned when a channel with the same name already exists (409 response)
ErrChannelAlreadyExists = apierror.ErrChannelAlreadyExists

// Client initialization errors
ErrOptionsRequired = errors.New("options is required")
Expand Down Expand Up @@ -137,6 +139,11 @@ func (c *Client) Create(ctx context.Context, input CreateInput) (*apiClient.Chan
"channel_id", resp.JSON201.ChannelId.String(),
"name", resp.JSON201.Name)
return resp.JSON201, nil
case http.StatusConflict:
c.logger.Warn("Conflict when creating channel",
"name", input.Name,
"code", apierror.ConflictCode(resp.JSON409))
return nil, apierror.WrapConflict(resp.JSON409, ErrCreateChannel, "name "+input.Name)
case http.StatusUnauthorized:
c.logger.Error("Unauthorized when creating channel",
"status_code", resp.StatusCode(),
Expand Down Expand Up @@ -314,6 +321,11 @@ func (c *Client) Update(ctx context.Context, channelID uuid.UUID, input UpdateIn
"code", apierror.NotFoundCode(resp.JSON404),
)
return nil, fmt.Errorf("%w: channel ID %s", ErrChannelNotFound, channelID.String())
case http.StatusConflict:
c.logger.Warn("Conflict when updating channel",
"channel_id", channelID.String(),
"code", apierror.ConflictCode(resp.JSON409))
return nil, apierror.WrapConflict(resp.JSON409, ErrUpdateChannel, "channel ID "+channelID.String())
Comment on lines +324 to +328
case http.StatusUnauthorized:
c.logger.Error("Unauthorized when updating channel",
"status_code", resp.StatusCode(),
Expand Down
87 changes: 85 additions & 2 deletions channels/channels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,12 +289,38 @@ func TestClient_Create(t *testing.T) {
assert.True(t, errors.Is(err, apierror.ErrUnexpectedStatusCode), "Expected apierror.ErrUnexpectedStatusCode, got: %v", err)
})

t.Run("AlreadyExists_UncodedFallback", func(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
require.NoError(t, json.NewEncoder(w).Encode(map[string]string{
"type": "CONFLICT",
"message": "Channel already exists",
}))
}

client, server := setupTestClient(t, handler)
defer server.Close()

channel, err := client.Create(context.Background(), CreateInput{
Name: "existing-channel",
})

require.Error(t, err)
assert.Nil(t, channel)
assert.True(t, errors.Is(err, ErrCreateChannel), "Expected ErrCreateChannel, got: %v", err)
assert.False(t, errors.Is(err, ErrChannelAlreadyExists),
"missing code must not resolve to a typed sentinel, got: %v", err)
})

t.Run("AlreadyExists", func(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
require.NoError(t, json.NewEncoder(w).Encode(map[string]string{
"error": "Channel already exists",
"type": "CONFLICT",
"code": "CHANNEL_ALREADY_EXISTS",
"message": "Channel already exists",
}))
}

Expand All @@ -308,7 +334,7 @@ func TestClient_Create(t *testing.T) {
require.Error(t, err)
assert.Nil(t, channel)
assert.True(t, errors.Is(err, ErrCreateChannel), "Expected ErrCreateChannel, got: %v", err)
assert.True(t, errors.Is(err, apierror.ErrUnexpectedStatusCode), "Expected apierror.ErrUnexpectedStatusCode, got: %v", err)
assert.True(t, errors.Is(err, ErrChannelAlreadyExists), "Expected ErrChannelAlreadyExists, got: %v", err)
})

t.Run("OrganizationNotFound", func(t *testing.T) {
Expand Down Expand Up @@ -758,6 +784,63 @@ func TestClient_Update(t *testing.T) {
assert.True(t, errors.Is(err, ErrUpdateChannel))
assert.True(t, errors.Is(err, apierror.ErrUnexpectedStatusCode))
})

t.Run("Conflict_ChannelAlreadyExists", func(t *testing.T) {
channelID := uuid.New()

handler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
code := apiClient.ApplicationErrorCodeChannelAlreadyExists
require.NoError(t, json.NewEncoder(w).Encode(apiClient.ApplicationError{
Code: &code,
Message: "channel already exists",
Type: apiClient.CONFLICT,
}))
}

client, server := setupTestClient(t, handler)
defer server.Close()

desc := "description"
channel, err := client.Update(context.Background(), channelID, UpdateInput{
Name: "updated-channel",
Description: &desc,
})

require.Error(t, err)
assert.Nil(t, channel)
assert.ErrorIs(t, err, ErrUpdateChannel)
assert.ErrorIs(t, err, ErrChannelAlreadyExists)
})

t.Run("Conflict_UnknownCode", func(t *testing.T) {
channelID := uuid.New()

handler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
// No code field — exercises the fallback path in WrapConflict.
require.NoError(t, json.NewEncoder(w).Encode(apiClient.ApplicationError{
Message: "conflict without a recognized code",
Type: apiClient.CONFLICT,
}))
}

client, server := setupTestClient(t, handler)
defer server.Close()

desc := "description"
channel, err := client.Update(context.Background(), channelID, UpdateInput{
Name: "updated-channel",
Description: &desc,
})

require.Error(t, err)
assert.Nil(t, channel)
assert.ErrorIs(t, err, ErrUpdateChannel)
assert.False(t, errors.Is(err, ErrChannelAlreadyExists), "unknown code must not produce ErrChannelAlreadyExists")
})
}

func TestClient_Archive(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ require (
github.com/oapi-codegen/runtime v1.1.2
github.com/smartcontractkit/chain-selectors v1.0.89
github.com/smartcontractkit/chainlink-common v0.10.0
github.com/smartcontractkit/crec-api-go v0.7.10
github.com/smartcontractkit/crec-api-go v0.7.11-0.20260722130115-076b94957db3
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.38.0
github.com/testcontainers/testcontainers-go/modules/vault v0.38.0
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -435,10 +435,8 @@ github.com/smartcontractkit/chainlink-common v0.10.0 h1:d90b9UPJecrIryzhl43F1oQw
github.com/smartcontractkit/chainlink-common v0.10.0/go.mod h1:13YN2kb3Vqpw2S7d4IwhX/578WPGC0JHN5JrOnAEsOc=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260210221717-2546aed27ebe h1:Vc4zoSc/j6/FdCQ7vcyHTTB7kzHI2f+lHCHqFuiCcJQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260210221717-2546aed27ebe/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
github.com/smartcontractkit/crec-api-go v0.7.9 h1:cJrgMIDmUc5eEmIOACYm47AR7CP9YDQHygIpqHSbDek=
github.com/smartcontractkit/crec-api-go v0.7.9/go.mod h1:y91qqcZFtWiKLFu66c/dmBp10bTzcpGcb4XFR7eLknk=
github.com/smartcontractkit/crec-api-go v0.7.10 h1:UX8Atpzgf3H+IJn9p6yxy59hQr6b7BLZ4rbooTbr3IU=
github.com/smartcontractkit/crec-api-go v0.7.10/go.mod h1:y91qqcZFtWiKLFu66c/dmBp10bTzcpGcb4XFR7eLknk=
github.com/smartcontractkit/crec-api-go v0.7.11-0.20260722130115-076b94957db3 h1:/5mlqfNCtafB5wvKGpE/Bn4dIWfZg4jV265Gnhma1MA=
github.com/smartcontractkit/crec-api-go v0.7.11-0.20260722130115-076b94957db3/go.mod h1:y91qqcZFtWiKLFu66c/dmBp10bTzcpGcb4XFR7eLknk=
github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d h1:LokA9PoCNb8mm8mDT52c3RECPMRsGz1eCQORq+J3n74=
github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d/go.mod h1:Acy3BTBxou83ooMESLO90s8PKSu7RvLCzwSTbxxfOK0=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
Expand Down
Loading
Loading