Skip to content

Commit a817871

Browse files
authored
Add typed error codes for better downstream client usage of error handling (#525)
* Add typed error codes for better downstream client usage of error handling * fix go vet * fix govet
1 parent a7d71f1 commit a817871

File tree

9 files changed

+143
-67
lines changed

9 files changed

+143
-67
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Feature
2+
body: Add error codes for different types of error to support better client error handling
3+
time: 2025-03-27T12:55:44.683709-05:00

actions.go

+9-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package opslevel
22

33
import (
4+
"errors"
45
"fmt"
56
)
67

@@ -51,7 +52,7 @@ type CustomActionsExternalActionsConnection struct {
5152
type CustomActionsTriggerDefinitionsConnection struct {
5253
Nodes []CustomActionsTriggerDefinition
5354
PageInfo PageInfo
54-
TotalCount int
55+
TotalCount int `graphql:"-"`
5556
}
5657

5758
func (client *Client) CreateWebhookAction(input CustomActionsWebhookActionCreateInput) (*CustomActionsExternalAction, error) {
@@ -65,7 +66,7 @@ func (client *Client) CreateWebhookAction(input CustomActionsWebhookActionCreate
6566
"input": input,
6667
}
6768
err := client.Mutate(&m, v, WithName("WebhookActionCreate"))
68-
return &m.Payload.WebhookAction, HandleErrors(err, m.Payload.Errors)
69+
return &m.Payload.WebhookAction, errors.Join(err, HasAPIErrors(m.Payload.Errors))
6970
}
7071

7172
func (client *Client) GetCustomAction(input string) (*CustomActionsExternalAction, error) {
@@ -78,10 +79,7 @@ func (client *Client) GetCustomAction(input string) (*CustomActionsExternalActio
7879
"input": *NewIdentifier(input),
7980
}
8081
err := client.Query(&q, v, WithName("ExternalActionGet"))
81-
if q.Account.Action.CustomActionsId.Id == "" {
82-
err = fmt.Errorf("CustomActionsExternalAction with ID or Alias matching '%s' not found", input)
83-
}
84-
return &q.Account.Action, HandleErrors(err, nil)
82+
return &q.Account.Action, HandleErrors(err, IsResourceFound(&q.Account.Action.CustomActionsId))
8583
}
8684

8785
func (client *Client) ListCustomActions(variables *PayloadVariables) (*CustomActionsExternalActionsConnection, error) {
@@ -160,10 +158,7 @@ func (client *Client) GetTriggerDefinition(input string) (*CustomActionsTriggerD
160158
"input": *NewIdentifier(input),
161159
}
162160
err := client.Query(&q, v, WithName("TriggerDefinitionGet"))
163-
if q.Account.Definition.Id == "" {
164-
err = fmt.Errorf("CustomActionsTriggerDefinition with ID or Alias matching '%s' not found", input)
165-
}
166-
return &q.Account.Definition, HandleErrors(err, nil)
161+
return &q.Account.Definition, errors.Join(err, IsResourceFound(&q.Account.Definition))
167162
}
168163

169164
func (client *Client) ListTriggerDefinitions(variables *PayloadVariables) (*CustomActionsTriggerDefinitionsConnection, error) {
@@ -176,19 +171,20 @@ func (client *Client) ListTriggerDefinitions(variables *PayloadVariables) (*Cust
176171
variables = client.InitialPageVariablesPointer()
177172
}
178173
if err := client.Query(&q, *variables, WithName("TriggerDefinitionList")); err != nil {
179-
return nil, err
174+
return nil, HandleErrors(err)
180175
}
176+
q.Account.Definitions.TotalCount = len(q.Account.Definitions.Nodes)
181177
for q.Account.Definitions.PageInfo.HasNextPage {
182178
(*variables)["after"] = q.Account.Definitions.PageInfo.End
183179
resp, err := client.ListTriggerDefinitions(variables)
184180
if err != nil {
185-
return nil, err
181+
return &q.Account.Definitions, HandleErrors(err)
186182
}
187183
q.Account.Definitions.Nodes = append(q.Account.Definitions.Nodes, resp.Nodes...)
188184
q.Account.Definitions.PageInfo = resp.PageInfo
189185
q.Account.Definitions.TotalCount += resp.TotalCount
190186
}
191-
return &q.Account.Definitions, nil
187+
return &q.Account.Definitions, HandleErrors(IsResourceFound(&q.Account.Definitions))
192188
}
193189

194190
func (client *Client) UpdateTriggerDefinition(input CustomActionsTriggerDefinitionUpdateInput) (*CustomActionsTriggerDefinition, error) {

actions_test.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -225,14 +225,14 @@ func TestGetTriggerDefinition(t *testing.T) {
225225
func TestListTriggerDefinitions(t *testing.T) {
226226
// Arrange
227227
testRequestOne := autopilot.NewTestRequest(
228-
`query TriggerDefinitionList($after:String!$first:Int!){account{customActionsTriggerDefinitions(after: $after, first: $first){nodes{{ template "custom_actions_trigger_request" }},{{ template "pagination_request" }},totalCount}}}`,
228+
`query TriggerDefinitionList($after:String!$first:Int!){account{customActionsTriggerDefinitions(after: $after, first: $first){nodes{{ template "custom_actions_trigger_request" }},{{ template "pagination_request" }}}}}`,
229229
`{{ template "pagination_initial_query_variables" }}`,
230-
`{ "data": { "account": { "customActionsTriggerDefinitions": { "nodes": [ { {{ template "custom_action_trigger1_response" }} }, { {{ template "custom_action_trigger2_response" }} } ], {{ template "pagination_initial_pageInfo_response" }}, "totalCount": 2 }}}}`,
230+
`{ "data": { "account": { "customActionsTriggerDefinitions": { "nodes": [ { {{ template "custom_action_trigger1_response" }} }, { {{ template "custom_action_trigger2_response" }} } ], {{ template "pagination_initial_pageInfo_response" }} }}}}`,
231231
)
232232
testRequestTwo := autopilot.NewTestRequest(
233-
`query TriggerDefinitionList($after:String!$first:Int!){account{customActionsTriggerDefinitions(after: $after, first: $first){nodes{{ template "custom_actions_trigger_request" }},{{ template "pagination_request" }},totalCount}}}`,
233+
`query TriggerDefinitionList($after:String!$first:Int!){account{customActionsTriggerDefinitions(after: $after, first: $first){nodes{{ template "custom_actions_trigger_request" }},{{ template "pagination_request" }}}}}`,
234234
`{{ template "pagination_second_query_variables" }}`,
235-
`{ "data": { "account": { "customActionsTriggerDefinitions": { "nodes": [ { {{ template "custom_action_trigger3_response" }} } ], {{ template "pagination_second_pageInfo_response" }}, "totalCount": 1 }}}}`,
235+
`{ "data": { "account": { "customActionsTriggerDefinitions": { "nodes": [ { {{ template "custom_action_trigger3_response" }} } ], {{ template "pagination_second_pageInfo_response" }} }}}}`,
236236
)
237237
requests := []autopilot.TestRequest{testRequestOne, testRequestTwo}
238238

common.go

-41
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
package opslevel
22

33
import (
4-
"errors"
5-
"fmt"
64
"slices"
7-
"strings"
85
"time"
96

10-
"github.com/hasura/go-graphql-client"
117
"github.com/relvacode/iso8601"
128
)
139

@@ -52,43 +48,6 @@ func RefTo[T NullableConstraint](value T) *Nullable[T] {
5248
return NewNullableFrom(value)
5349
}
5450

55-
func HandleErrors(err error, errs []Error) error {
56-
if err != nil {
57-
return err
58-
}
59-
return FormatErrors(errs)
60-
}
61-
62-
func FormatErrors(errs []Error) error {
63-
if len(errs) == 0 {
64-
return nil
65-
}
66-
67-
allErrors := fmt.Errorf("OpsLevel API Errors:")
68-
for _, err := range errs {
69-
if len(err.Path) == 1 && err.Path[0] == "base" {
70-
err.Path[0] = ""
71-
}
72-
newErr := fmt.Errorf("\t- '%s' %s", strings.Join(err.Path, "."), err.Message)
73-
allErrors = errors.Join(allErrors, newErr)
74-
}
75-
76-
return allErrors
77-
}
78-
79-
// IsOpsLevelApiError checks if the error is returned by OpsLevel's API
80-
func IsOpsLevelApiError(err error) bool {
81-
if _, ok := err.(graphql.Errors); !ok {
82-
return false
83-
}
84-
for _, hasuraErr := range err.(graphql.Errors) {
85-
if len(hasuraErr.Path) > 0 {
86-
return true
87-
}
88-
}
89-
return false
90-
}
91-
9251
func NewISO8601Date(datetime string) iso8601.Time {
9352
date, _ := iso8601.ParseString(datetime)
9453
return iso8601.Time{Time: date}

errors.go

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package opslevel
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/hasura/go-graphql-client"
9+
)
10+
11+
type ErrorCode int
12+
13+
const (
14+
ErrorUnknown ErrorCode = iota
15+
ErrorRequestError
16+
ErrorAPIError
17+
ErrorResourceNotFound
18+
)
19+
20+
type ClientError struct {
21+
error
22+
ErrorCode ErrorCode
23+
}
24+
25+
func NewClientError(code ErrorCode, err error) error {
26+
return &ClientError{
27+
error: err,
28+
ErrorCode: code,
29+
}
30+
}
31+
32+
func HandleErrors(opts ...any) error {
33+
output := (error)(nil)
34+
for _, opt := range opts {
35+
switch v := opt.(type) {
36+
case error:
37+
if !IsOpsLevelApiError(v) {
38+
output = errors.Join(output, NewClientError(ErrorRequestError, v))
39+
} else {
40+
output = errors.Join(output, v)
41+
}
42+
case []Error:
43+
output = errors.Join(output, HasAPIErrors(v))
44+
}
45+
}
46+
return output
47+
}
48+
49+
func HasAPIErrors(errs []Error) error {
50+
if len(errs) == 0 {
51+
return nil
52+
}
53+
54+
message := "OpsLevel API Errors:"
55+
for _, e := range errs {
56+
if len(e.Path) == 1 && e.Path[0] == "base" {
57+
e.Path[0] = ""
58+
}
59+
message += fmt.Sprintf("\n\t- '%s' %s", strings.Join(e.Path, "."), e.Message)
60+
}
61+
62+
return NewClientError(ErrorAPIError, errors.New(message))
63+
}
64+
65+
func IsResourceFound(resource any) error {
66+
// TODO: Also Check if ID is valid somehow `.Id == ""`
67+
if resource == nil {
68+
return NewClientError(ErrorResourceNotFound, fmt.Errorf("resource '%T' not found", resource))
69+
}
70+
if casted, ok := resource.(Identifiable); ok && casted.GetID() == "" {
71+
return NewClientError(ErrorResourceNotFound, fmt.Errorf("resource '%T' not found", resource))
72+
}
73+
return nil
74+
}
75+
76+
func ErrIs(err error, code ErrorCode) bool {
77+
var clientErr *ClientError
78+
if errors.As(err, &clientErr) {
79+
if clientErr.ErrorCode == code {
80+
return true
81+
}
82+
}
83+
return false
84+
}
85+
86+
// IsOpsLevelApiError checks if the error is returned by OpsLevel's API
87+
func IsOpsLevelApiError(err error) bool {
88+
if _, ok := err.(graphql.Errors); !ok {
89+
return false
90+
}
91+
for _, hasuraErr := range err.(graphql.Errors) {
92+
if len(hasuraErr.Path) > 0 {
93+
return true
94+
}
95+
}
96+
return false
97+
}

common_test.go errors_test.go

+17-4
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,51 @@
11
package opslevel_test
22

33
import (
4+
"fmt"
45
"testing"
56

67
ol "github.com/opslevel/opslevel-go/v2025"
78
"github.com/rocktavious/autopilot/v2023"
89
)
910

10-
func TestFormatErrorsWorks(t *testing.T) {
11+
func TestHasAPIErrorsWorks(t *testing.T) {
1112
// Arrange
1213
errs := []ol.Error{
1314
{Message: "can't be blank", Path: []string{"resource", "id"}},
1415
{Message: "is not a valid input", Path: []string{"id"}},
1516
}
1617
// Act
17-
output := ol.FormatErrors(errs)
18+
output := ol.HasAPIErrors(errs)
1819
// Assert
20+
autopilot.Equals(t, true, ol.ErrIs(output, ol.ErrorAPIError))
1921
autopilot.Equals(t, `OpsLevel API Errors:
2022
- 'resource.id' can't be blank
2123
- 'id' is not a valid input`,
2224
output.Error())
2325
}
2426

25-
func TestFormatErrorsNoPath(t *testing.T) {
27+
func TestHasAPIErrorsNoPath(t *testing.T) {
2628
// Arrange
2729
errs := []ol.Error{
2830
{Message: "can't be blank", Path: []string{"base"}},
2931
{Message: "is not a valid input", Path: []string{""}},
3032
}
3133
// Act
32-
output := ol.FormatErrors(errs)
34+
output := ol.HasAPIErrors(errs)
3335
// Assert
36+
autopilot.Equals(t, true, ol.ErrIs(output, ol.ErrorAPIError))
3437
autopilot.Equals(t, `OpsLevel API Errors:
3538
- '' can't be blank
3639
- '' is not a valid input`,
3740
output.Error())
3841
}
42+
43+
func TestIsResourceFoundError(t *testing.T) {
44+
// Arrange
45+
err1 := ol.NewClientError(ol.ErrorResourceNotFound, fmt.Errorf("resource 'Example' not found"))
46+
err2 := ol.NewClientError(ol.ErrorAPIError, fmt.Errorf("resource 'Example' not found"))
47+
// Act
48+
// Assert
49+
autopilot.Equals(t, true, ol.ErrIs(err1, ol.ErrorResourceNotFound))
50+
autopilot.Equals(t, false, ol.ErrIs(err2, ol.ErrorResourceNotFound))
51+
}

scalar.go

+8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import (
88
"strings"
99
)
1010

11+
type Identifiable interface {
12+
GetID() ID
13+
}
14+
1115
type ID string
1216

1317
func NewID(id ...string) *ID {
@@ -32,6 +36,10 @@ type Identifier struct {
3236
Aliases []string `graphql:"aliases" json:"aliases"`
3337
}
3438

39+
func (s Identifier) GetID() ID {
40+
return s.Id
41+
}
42+
3543
func (identifierInput IdentifierInput) MarshalJSON() ([]byte, error) {
3644
if identifierInput.Id == nil && identifierInput.Alias == nil {
3745
return []byte("null"), nil

service.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ func (client *Client) CreateService(input ServiceCreateInput) (*Service, error)
321321
if err := m.Payload.Service.Hydrate(client); err != nil {
322322
return &m.Payload.Service, err
323323
}
324-
return &m.Payload.Service, FormatErrors(m.Payload.Errors)
324+
return &m.Payload.Service, HandleErrors(m.Payload.Errors)
325325
}
326326

327327
func (client *Client) GetServiceIdWithAlias(alias string) (*ServiceId, error) {
@@ -723,7 +723,7 @@ func (client *Client) UpdateService(input ServiceUpdateInput) (*Service, error)
723723
if err := m.Payload.Service.Hydrate(client); err != nil {
724724
return &m.Payload.Service, err
725725
}
726-
return &m.Payload.Service, FormatErrors(m.Payload.Errors)
726+
return &m.Payload.Service, HandleErrors(m.Payload.Errors)
727727
}
728728

729729
func (client *Client) UpdateServiceNote(input ServiceNoteUpdateInput) (*Service, error) {
@@ -738,7 +738,7 @@ func (client *Client) UpdateServiceNote(input ServiceNoteUpdateInput) (*Service,
738738
return nil, err
739739
}
740740

741-
return &m.Payload.Service, FormatErrors(m.Payload.Errors)
741+
return &m.Payload.Service, HandleErrors(m.Payload.Errors)
742742
}
743743

744744
func (client *Client) DeleteService(identifier string) error {

team.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ func (client *Client) CreateTeam(input TeamCreateInput) (*Team, error) {
253253
if err := m.Payload.Team.Hydrate(client); err != nil {
254254
return &m.Payload.Team, err
255255
}
256-
return &m.Payload.Team, FormatErrors(m.Payload.Errors)
256+
return &m.Payload.Team, HandleErrors(m.Payload.Errors)
257257
}
258258

259259
func (client *Client) AddMemberships(team *TeamId, memberships ...TeamMembershipUserInput) ([]TeamMembership, error) {
@@ -421,7 +421,7 @@ func (client *Client) UpdateTeam(input TeamUpdateInput) (*Team, error) {
421421
if err := m.Payload.Team.Hydrate(client); err != nil {
422422
return &m.Payload.Team, err
423423
}
424-
return &m.Payload.Team, FormatErrors(m.Payload.Errors)
424+
return &m.Payload.Team, HandleErrors(m.Payload.Errors)
425425
}
426426

427427
func (client *Client) UpdateContact(id ID, contact ContactInput) (*Contact, error) {

0 commit comments

Comments
 (0)