Skip to content

Commit c7d7096

Browse files
authored
Failure Converter (#29)
💥 BREAKING CHANGE 💥 - Add a `FailureConverter` to convert from a `Failure` to `error` and back and remove `Failure` from the main SDK APIs. - Changed `HandlerError` to take a `Cause error` instead of a `Failure` object. - `HandlerError` gets an `Unwrap()` method for use with helpers in the `errors` package. - Changed `UnsuccessfulOperationError` to take a `Cause error` instead of a `Failure` object. - `UnsuccessfulOperationError` gets an `Unwrap()` method for use with helpers in the `errors` package. - Added new shorthand constructors for `UnsuccessfulOperationError`: `NewFailedOperationError` and `NewCanceledOperationError`. - Added a new `NewOperationCompletionUnsuccessful` constructor that takes an error object and options to support a failure converter. - Rename `StartLinks` to `Links`.
1 parent de4f51a commit c7d7096

17 files changed

+413
-147
lines changed

README.md

+9-14
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,11 @@ result, err := nexus.StartOperation(ctx, client, operation, MyInput{Field: "valu
6060
if err != nil {
6161
var unsuccessfulOperationError *nexus.UnsuccessfulOperationError
6262
if errors.As(err, &unsuccessfulOperationError) { // operation failed or canceled
63-
fmt.Printf("Operation unsuccessful with state: %s, failure message: %s\n", unsuccessfulOperationError.State, unsuccessfulOperationError.Failure.Message)
63+
fmt.Printf("Operation unsuccessful with state: %s, failure message: %s\n", unsuccessfulOperationError.State, unsuccessfulOperationError.Cause.Error())
6464
}
6565
var handlerError *nexus.HandlerError
6666
if errors.As(err, &handlerError) {
67-
fmt.Printf("Handler returned an error, type: %s, failure message: %s\n", handlerError.Type, handlerError.Failure.Message)
67+
fmt.Printf("Handler returned an error, type: %s, failure message: %s\n", handlerError.Type, handlerError.Cause.Error())
6868
}
6969
// most other errors should be returned as *nexus.UnexpectedResponseError
7070
}
@@ -209,16 +209,13 @@ _, err = io.ReadAll(response.Body)
209209
fmt.Println("delivered completion with status code", response.StatusCode)
210210
```
211211

212-
To deliver failed and canceled completions, pass a `OperationCompletionUnsuccessful` struct pointer with the failure and
213-
state attached.
212+
To deliver failed and canceled completions, pass a `OperationCompletionUnsuccessful` struct pointer constructed with
213+
`NewOperationCompletionUnsuccessful`.
214214

215215
Custom HTTP headers may be provided via `OperationCompletionUnsuccessful.Header`.
216216

217217
```go
218-
completion := &OperationCompletionUnsuccessful{
219-
State: nexus.OperationStateCanceled,
220-
Failure: &nexus.Failure{Message: "canceled as requested"},
221-
}
218+
completion := nexus.NewOperationCompletionUnsuccessful(nexus.NewOperationFailedError(fmt.Errorf("some error")), nexus.OperationCompletionUnsuccessfulOptions{})
222219
request, _ := nexus.NewCompletionHTTPRequest(ctx, callbackURL, completion)
223220
// ...
224221
```
@@ -290,10 +287,8 @@ _ = http.Serve(listener, httpHandler)
290287

291288
```go
292289
func (h *myArbitraryLengthOperation) Start(ctx context.Context, input MyInput, options nexus.StartOperationOptions) (nexus.HandlerStartOperationResult[MyOutput], error) {
293-
return nil, &nexus.UnsuccessfulOperationError{
294-
State: nexus.OperationStateFailed, // or OperationStateCanceled
295-
Failure: &nexus.Failure{Message: "Do or do not, there is not try"},
296-
}
290+
// Alternatively use NewCanceledOperationError to resolve an operation as canceled.
291+
return nil, nexus.NewFailedOperationError(errors.New("Do or do not, there is not try"))
297292
}
298293
```
299294

@@ -329,14 +324,14 @@ func (h *myArbitraryLengthOperation) GetResult(ctx context.Context, id string, o
329324
}
330325
// Optionally translate to operation failure (could also result in canceled state).
331326
// Optionally expose the error details to the caller.
332-
return nil, &nexus.UnsuccessfulOperationError{State: nexus.OperationStateFailed, Failure: nexus.Failure{Message: err.Error()}}
327+
return nil, nexus.NewFailedOperationError(err)
333328
}
334329
return result, nil
335330
} else {
336331
result, err := h.peekOperation(ctx)
337332
if err != nil {
338333
// Optionally translate to operation failure (could also result in canceled state).
339-
return nil, &nexus.UnsuccessfulOperationError{State: nexus.OperationStateFailed, Failure: nexus.Failure{Message: err.Error()}}
334+
return nil, nexus.NewFailedOperationError(err)
340335
}
341336
return result, nil
342337
}

nexus/api.go

+45-6
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ const (
5353
StatusUpstreamTimeout = 520
5454
)
5555

56-
// A Failure represents failed handler invocations as well as `failed` or `canceled` operation results.
56+
// A Failure represents failed handler invocations as well as `failed` or `canceled` operation results. Failures
57+
// shouldn't typically be constructed directly. The SDK APIs take a [FailureConverter] instance that can translate
58+
// language errors to and from [Failure] instances.
5759
type Failure struct {
5860
// A simple text message.
5961
Message string `json:"message"`
@@ -63,18 +65,55 @@ type Failure struct {
6365
Details json.RawMessage `json:"details,omitempty"`
6466
}
6567

68+
// An error that directly represents a wire representation of [Failure].
69+
// The SDK will convert to this error by default unless the [FailureConverter] instance is customized.
70+
type FailureError struct {
71+
// The underlying Failure object this error represents.
72+
Failure Failure
73+
}
74+
75+
// Error implements the error interface.
76+
func (e *FailureError) Error() string {
77+
return e.Failure.Message
78+
}
79+
6680
// UnsuccessfulOperationError represents "failed" and "canceled" operation results.
6781
type UnsuccessfulOperationError struct {
68-
State OperationState
69-
Failure Failure
82+
// State of the operation. Only [OperationStateFailed] and [OperationStateCanceled] are valid.
83+
State OperationState
84+
// The underlying cause for this error.
85+
Cause error
86+
}
87+
88+
// NewFailedOperationError is shorthand for constructing an [UnsuccessfulOperationError] with State set to
89+
// [OperationStateFailed] and the given err as the Cause.
90+
func NewFailedOperationError(err error) *UnsuccessfulOperationError {
91+
return &UnsuccessfulOperationError{
92+
State: OperationStateFailed,
93+
Cause: err,
94+
}
95+
}
96+
97+
// NewFailedOperationError is shorthand for constructing an [UnsuccessfulOperationError] with State set to
98+
// [OperationStateCanceled] and the given err as the Cause.
99+
func NewCanceledOperationError(err error) *UnsuccessfulOperationError {
100+
return &UnsuccessfulOperationError{
101+
State: OperationStateCanceled,
102+
Cause: err,
103+
}
70104
}
71105

72106
// Error implements the error interface.
73107
func (e *UnsuccessfulOperationError) Error() string {
74-
if e.Failure.Message != "" {
75-
return fmt.Sprintf("operation %s: %s", e.State, e.Failure.Message)
108+
if e.Cause == nil {
109+
return fmt.Sprintf("operation %s", e.State)
76110
}
77-
return fmt.Sprintf("operation %s", e.State)
111+
return fmt.Sprintf("operation %s: %s", e.State, e.Cause.Error())
112+
}
113+
114+
// Unwrap returns the cause for use with utilities in the errors package.
115+
func (e *UnsuccessfulOperationError) Unwrap() error {
116+
return e.Cause
78117
}
79118

80119
// ErrOperationStillRunning indicates that an operation is still running while trying to get its result.

nexus/client.go

+40-27
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ type HTTPClientOptions struct {
2727
// Defaults to [http.DefaultClient.Do].
2828
HTTPCaller func(*http.Request) (*http.Response, error)
2929
// A [Serializer] to customize client serialization behavior.
30-
// By default the client handles, JSONables, byte slices, and nil.
30+
// By default the client handles JSONables, byte slices, and nil.
3131
Serializer Serializer
32+
// A [FailureConverter] to convert a [Failure] instance to and from an [error]. Defaults to
33+
// [DefaultFailureConverter].
34+
FailureConverter FailureConverter
3235
}
3336

3437
// User-Agent header set on HTTP requests.
@@ -114,6 +117,9 @@ func NewHTTPClient(options HTTPClientOptions) (*HTTPClient, error) {
114117
if options.Serializer == nil {
115118
options.Serializer = defaultSerializer
116119
}
120+
if options.FailureConverter == nil {
121+
options.FailureConverter = defaultFailureConverter
122+
}
117123

118124
return &HTTPClient{
119125
options: options,
@@ -267,17 +273,18 @@ func (c *HTTPClient) StartOperation(
267273
return nil, err
268274
}
269275

270-
failure, err := failureFromResponse(response, body)
276+
failure, err := c.failureFromResponse(response, body)
271277
if err != nil {
272278
return nil, err
273279
}
274280

281+
failureErr := c.options.FailureConverter.FailureToError(failure)
275282
return nil, &UnsuccessfulOperationError{
276-
State: state,
277-
Failure: failure,
283+
State: state,
284+
Cause: failureErr,
278285
}
279286
default:
280-
return nil, bestEffortHandlerErrorFromResponse(response, body)
287+
return nil, c.bestEffortHandlerErrorFromResponse(response, body)
281288
}
282289
}
283290

@@ -393,7 +400,7 @@ func operationInfoFromResponse(response *http.Response, body []byte) (*Operation
393400
return &info, nil
394401
}
395402

396-
func failureFromResponse(response *http.Response, body []byte) (Failure, error) {
403+
func (c *HTTPClient) failureFromResponse(response *http.Response, body []byte) (Failure, error) {
397404
if !isMediaTypeJSON(response.Header.Get("Content-Type")) {
398405
return Failure{}, newUnexpectedResponseError(fmt.Sprintf("invalid response content type: %q", response.Header.Get("Content-Type")), response, body)
399406
}
@@ -402,43 +409,49 @@ func failureFromResponse(response *http.Response, body []byte) (Failure, error)
402409
return failure, err
403410
}
404411

405-
func failureFromResponseOrDefault(response *http.Response, body []byte, defaultMessage string) Failure {
406-
failure, err := failureFromResponse(response, body)
412+
func (c *HTTPClient) failureFromResponseOrDefault(response *http.Response, body []byte, defaultMessage string) Failure {
413+
failure, err := c.failureFromResponse(response, body)
407414
if err != nil {
408415
failure.Message = defaultMessage
409416
}
410417
return failure
411418
}
412419

413-
func bestEffortHandlerErrorFromResponse(response *http.Response, body []byte) error {
420+
func (c *HTTPClient) failureErrorFromResponseOrDefault(response *http.Response, body []byte, defaultMessage string) error {
421+
failure := c.failureFromResponseOrDefault(response, body, defaultMessage)
422+
failureErr := c.options.FailureConverter.FailureToError(failure)
423+
return failureErr
424+
}
425+
426+
func (c *HTTPClient) bestEffortHandlerErrorFromResponse(response *http.Response, body []byte) error {
414427
switch response.StatusCode {
415428
case http.StatusBadRequest:
416-
failure := failureFromResponseOrDefault(response, body, "bad request")
417-
return &HandlerError{Type: HandlerErrorTypeBadRequest, Failure: &failure}
429+
failureErr := c.failureErrorFromResponseOrDefault(response, body, "bad request")
430+
return &HandlerError{Type: HandlerErrorTypeBadRequest, Cause: failureErr}
418431
case http.StatusUnauthorized:
419-
failure := failureFromResponseOrDefault(response, body, "unauthenticated")
420-
return &HandlerError{Type: HandlerErrorTypeUnauthenticated, Failure: &failure}
432+
failureErr := c.failureErrorFromResponseOrDefault(response, body, "unauthenticated")
433+
return &HandlerError{Type: HandlerErrorTypeUnauthenticated, Cause: failureErr}
421434
case http.StatusForbidden:
422-
failure := failureFromResponseOrDefault(response, body, "unauthorized")
423-
return &HandlerError{Type: HandlerErrorTypeUnauthorized, Failure: &failure}
435+
failureErr := c.failureErrorFromResponseOrDefault(response, body, "unauthorized")
436+
return &HandlerError{Type: HandlerErrorTypeUnauthorized, Cause: failureErr}
424437
case http.StatusNotFound:
425-
failure := failureFromResponseOrDefault(response, body, "not found")
426-
return &HandlerError{Type: HandlerErrorTypeNotFound, Failure: &failure}
438+
failureErr := c.failureErrorFromResponseOrDefault(response, body, "not found")
439+
return &HandlerError{Type: HandlerErrorTypeNotFound, Cause: failureErr}
427440
case http.StatusTooManyRequests:
428-
failure := failureFromResponseOrDefault(response, body, "resource exhausted")
429-
return &HandlerError{Type: HandlerErrorTypeResourceExhausted, Failure: &failure}
441+
failureErr := c.failureErrorFromResponseOrDefault(response, body, "resource exhausted")
442+
return &HandlerError{Type: HandlerErrorTypeResourceExhausted, Cause: failureErr}
430443
case http.StatusInternalServerError:
431-
failure := failureFromResponseOrDefault(response, body, "internal error")
432-
return &HandlerError{Type: HandlerErrorTypeInternal, Failure: &failure}
444+
failureErr := c.failureErrorFromResponseOrDefault(response, body, "internal error")
445+
return &HandlerError{Type: HandlerErrorTypeInternal, Cause: failureErr}
433446
case http.StatusNotImplemented:
434-
failure := failureFromResponseOrDefault(response, body, "not implemented")
435-
return &HandlerError{Type: HandlerErrorTypeNotImplemented, Failure: &failure}
447+
failureErr := c.failureErrorFromResponseOrDefault(response, body, "not implemented")
448+
return &HandlerError{Type: HandlerErrorTypeNotImplemented, Cause: failureErr}
436449
case http.StatusServiceUnavailable:
437-
failure := failureFromResponseOrDefault(response, body, "unavailable")
438-
return &HandlerError{Type: HandlerErrorTypeUnavailable, Failure: &failure}
450+
failureErr := c.failureErrorFromResponseOrDefault(response, body, "unavailable")
451+
return &HandlerError{Type: HandlerErrorTypeUnavailable, Cause: failureErr}
439452
case StatusUpstreamTimeout:
440-
failure := failureFromResponseOrDefault(response, body, "upstream timeout")
441-
return &HandlerError{Type: HandlerErrorTypeUpstreamTimeout, Failure: &failure}
453+
failureErr := c.failureErrorFromResponseOrDefault(response, body, "upstream timeout")
454+
return &HandlerError{Type: HandlerErrorTypeUpstreamTimeout, Cause: failureErr}
442455
default:
443456
return newUnexpectedResponseError(fmt.Sprintf("unexpected response status: %q", response.Status), response, body)
444457
}

nexus/client_example_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ func ExampleHTTPClient_StartOperation() {
2020
if err != nil {
2121
var unsuccessfulOperationError *nexus.UnsuccessfulOperationError
2222
if errors.As(err, &unsuccessfulOperationError) { // operation failed or canceled
23-
fmt.Printf("Operation unsuccessful with state: %s, failure message: %s\n", unsuccessfulOperationError.State, unsuccessfulOperationError.Failure.Message)
23+
fmt.Printf("Operation unsuccessful with state: %s, failure message: %s\n", unsuccessfulOperationError.State, unsuccessfulOperationError.Cause.Error())
2424
}
2525
var handlerError *nexus.HandlerError
2626
if errors.As(err, &handlerError) {
27-
fmt.Printf("Handler returned an error, type: %s, failure message: %s\n", handlerError.Type, handlerError.Failure.Message)
27+
fmt.Printf("Handler returned an error, type: %s, failure message: %s\n", handlerError.Type, handlerError.Cause.Error())
2828
}
2929
// most other errors should be returned as *nexus.UnexpectedResponseError
3030
}

nexus/completion.go

+43-7
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,36 @@ type OperationCompletionUnsuccessful struct {
142142
// Links are used to link back to the operation when a completion callback is received before a started response.
143143
Links []Link
144144
// Failure object to send with the completion.
145-
Failure *Failure
145+
Failure Failure
146+
}
147+
148+
// OperationCompletionUnsuccessfulOptions are options for [NewOperationCompletionUnsuccessful].
149+
type OperationCompletionUnsuccessfulOptions struct {
150+
// A [FailureConverter] to convert a [Failure] instance to and from an [error]. Defaults to
151+
// [DefaultFailureConverter].
152+
FailureConverter FailureConverter
153+
// OperationID is the unique ID for this operation. Used when a completion callback is received before a started response.
154+
OperationID string
155+
// StartTime is the time the operation started. Used when a completion callback is received before a started response.
156+
StartTime time.Time
157+
// Links are used to link back to the operation when a completion callback is received before a started response.
158+
Links []Link
159+
}
160+
161+
// NewOperationCompletionUnsuccessful constructs an [OperationCompletionUnsuccessful] from a given error.
162+
func NewOperationCompletionUnsuccessful(error *UnsuccessfulOperationError, options OperationCompletionUnsuccessfulOptions) (*OperationCompletionUnsuccessful, error) {
163+
if options.FailureConverter == nil {
164+
options.FailureConverter = defaultFailureConverter
165+
}
166+
167+
return &OperationCompletionUnsuccessful{
168+
Header: make(Header),
169+
State: error.State,
170+
Failure: options.FailureConverter.ErrorToFailure(error.Cause),
171+
OperationID: options.OperationID,
172+
StartTime: options.StartTime,
173+
Links: options.Links,
174+
}, nil
146175
}
147176

148177
func (c *OperationCompletionUnsuccessful) applyToHTTPRequest(request *http.Request) error {
@@ -185,10 +214,10 @@ type CompletionRequest struct {
185214
OperationID string
186215
// StartTime is the time the operation started. Used when a completion callback is received before a started response.
187216
StartTime time.Time
188-
// StartLinks are used to link back to the operation when a completion callback is received before a started response.
189-
StartLinks []Link
217+
// Links are used to link back to the operation when a completion callback is received before a started response.
218+
Links []Link
190219
// Parsed from request and set if State is failed or canceled.
191-
Failure *Failure
220+
Error error
192221
// Extracted from request and set if State is succeeded.
193222
Result *LazyValue
194223
}
@@ -209,6 +238,9 @@ type CompletionHandlerOptions struct {
209238
// A [Serializer] to customize handler serialization behavior.
210239
// By default the handler handles, JSONables, byte slices, and nil.
211240
Serializer Serializer
241+
// A [FailureConverter] to convert a [Failure] instance to and from an [error]. Defaults to
242+
// [DefaultFailureConverter].
243+
FailureConverter FailureConverter
212244
}
213245

214246
type completionHTTPHandler struct {
@@ -231,7 +263,7 @@ func (h *completionHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *h
231263
}
232264
}
233265
var decodeErr error
234-
if completion.StartLinks, decodeErr = getLinksFromHeader(request.Header); decodeErr != nil {
266+
if completion.Links, decodeErr = getLinksFromHeader(request.Header); decodeErr != nil {
235267
h.writeFailure(writer, HandlerErrorf(HandlerErrorTypeBadRequest, "failed to decode links from request headers"))
236268
return
237269
}
@@ -251,7 +283,7 @@ func (h *completionHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *h
251283
h.writeFailure(writer, HandlerErrorf(HandlerErrorTypeBadRequest, "failed to read Failure from request body"))
252284
return
253285
}
254-
completion.Failure = &failure
286+
completion.Error = h.failureConverter.FailureToError(failure)
255287
case OperationStateSucceeded:
256288
completion.Result = &LazyValue{
257289
serializer: h.options.Serializer,
@@ -277,10 +309,14 @@ func NewCompletionHTTPHandler(options CompletionHandlerOptions) http.Handler {
277309
if options.Serializer == nil {
278310
options.Serializer = defaultSerializer
279311
}
312+
if options.FailureConverter == nil {
313+
options.FailureConverter = defaultFailureConverter
314+
}
280315
return &completionHTTPHandler{
281316
options: options,
282317
baseHTTPHandler: baseHTTPHandler{
283-
logger: options.Logger,
318+
logger: options.Logger,
319+
failureConverter: options.FailureConverter,
284320
},
285321
}
286322
}

0 commit comments

Comments
 (0)