diff --git a/v2/pkg/engine/resolve/context.go b/v2/pkg/engine/resolve/context.go index 24dc8b172d..65d2d6b900 100644 --- a/v2/pkg/engine/resolve/context.go +++ b/v2/pkg/engine/resolve/context.go @@ -141,8 +141,8 @@ func (c *Context) SubgraphErrors() error { return c.subgraphErrors } -func (c *Context) appendSubgraphError(err error) { - c.subgraphErrors = errors.Join(c.subgraphErrors, err) +func (c *Context) appendSubgraphErrors(errs ...error) { + c.subgraphErrors = errors.Join(c.subgraphErrors, errors.Join(errs...)) } type Request struct { diff --git a/v2/pkg/engine/resolve/loader.go b/v2/pkg/engine/resolve/loader.go index 010ca75684..b83e78f319 100644 --- a/v2/pkg/engine/resolve/loader.go +++ b/v2/pkg/engine/resolve/loader.go @@ -7,7 +7,6 @@ import ( "encoding/json" goerrors "errors" "fmt" - "github.com/wundergraph/graphql-go-tools/v2/pkg/errorcodes" "net/http" "net/http/httptrace" "slices" @@ -16,6 +15,8 @@ import ( "sync" "time" + "github.com/wundergraph/graphql-go-tools/v2/pkg/errorcodes" + "github.com/buger/jsonparser" "github.com/cespare/xxhash/v2" "github.com/pkg/errors" @@ -559,6 +560,11 @@ func (l *Loader) mergeResult(fetchItem *FetchItem, res *result, items []*astjson } value, err := astjson.ParseBytesWithoutCache(res.out.Bytes()) if err != nil { + // Fall back to status code if parsing fails and non-2XX + if (res.statusCode > 0 && res.statusCode < 200) || res.statusCode >= 300 { + return l.renderErrorsStatusFallback(fetchItem, res, res.statusCode) + } + return l.renderErrorsFailedToFetch(fetchItem, res, invalidGraphQLResponse) } @@ -587,6 +593,14 @@ func (l *Loader) mergeResult(fetchItem *FetchItem, res *result, items []*astjson value = value.Get(res.postProcessing.SelectResponseDataPath...) // Check if the not set or null if astjson.ValueIsNull(value) { + // When: + // - No errors or data are present + // - Status code is not within the 2XX range + // We can fall back to a status code based error + if !hasErrors && ((res.statusCode > 0 && res.statusCode < 200) || res.statusCode >= 300) { + return l.renderErrorsStatusFallback(fetchItem, res, res.statusCode) + } + // If we didn't get any data nor errors, we return an error because the response is invalid // Returning an error here also avoids the need to walk over it later. if !hasErrors && !l.resolvable.options.ApolloCompatibilitySuppressFetchErrors { @@ -699,7 +713,7 @@ func (l *Loader) appendSubgraphError(res *result, fetchItem *FetchItem, value *a subgraphError.AppendDownstreamError(&gErr) } - l.ctx.appendSubgraphError(goerrors.Join(res.err, subgraphError)) + l.ctx.appendSubgraphErrors(res.err, subgraphError) return nil } @@ -1012,7 +1026,7 @@ func (l *Loader) addApolloRouterCompatibilityError(res *result) error { } func (l *Loader) renderErrorsFailedToFetch(fetchItem *FetchItem, res *result, reason string) error { - l.ctx.appendSubgraphError(goerrors.Join(res.err, NewSubgraphError(res.ds, fetchItem.ResponsePath, reason, res.statusCode))) + l.ctx.appendSubgraphErrors(res.err, NewSubgraphError(res.ds, fetchItem.ResponsePath, reason, res.statusCode)) errorObject, err := astjson.ParseWithoutCache(l.renderSubgraphBaseError(res.ds, fetchItem.ResponsePath, reason)) if err != nil { return err @@ -1022,6 +1036,25 @@ func (l *Loader) renderErrorsFailedToFetch(fetchItem *FetchItem, res *result, re return nil } +func (l *Loader) renderErrorsStatusFallback(fetchItem *FetchItem, res *result, statusCode int) error { + reason := fmt.Sprintf("%d", statusCode) + if statusText := http.StatusText(statusCode); statusText != "" { + reason += fmt.Sprintf(": %s", statusText) + } + + l.ctx.appendSubgraphErrors(res.err, NewSubgraphError(res.ds, fetchItem.ResponsePath, reason, res.statusCode)) + + errorObject, err := astjson.ParseWithoutCache(fmt.Sprintf(`{"message":"%s"}`, reason)) + if err != nil { + return err + } + + l.setSubgraphStatusCode([]*astjson.Value{errorObject}, res.statusCode) + + astjson.AppendToArray(l.resolvable.errors, errorObject) + return nil +} + func (l *Loader) renderSubgraphBaseError(ds DataSourceInfo, path, reason string) string { pathPart := l.renderAtPathErrorPart(path) if ds.Name == "" { @@ -1038,7 +1071,7 @@ func (l *Loader) renderSubgraphBaseError(ds DataSourceInfo, path, reason string) func (l *Loader) renderAuthorizationRejectedErrors(fetchItem *FetchItem, res *result) error { for i := range res.authorizationRejectedReasons { - l.ctx.appendSubgraphError(goerrors.Join(res.err, NewSubgraphError(res.ds, fetchItem.ResponsePath, res.authorizationRejectedReasons[i], res.statusCode))) + l.ctx.appendSubgraphErrors(res.err, NewSubgraphError(res.ds, fetchItem.ResponsePath, res.authorizationRejectedReasons[i], res.statusCode)) } pathPart := l.renderAtPathErrorPart(fetchItem.ResponsePath) extensionErrorCode := fmt.Sprintf(`"extensions":{"code":"%s"}`, errorcodes.UnauthorizedFieldOrType) @@ -1079,7 +1112,7 @@ func (l *Loader) renderAuthorizationRejectedErrors(fetchItem *FetchItem, res *re } func (l *Loader) renderRateLimitRejectedErrors(fetchItem *FetchItem, res *result) error { - l.ctx.appendSubgraphError(goerrors.Join(res.err, NewRateLimitError(res.ds.Name, fetchItem.ResponsePath, res.rateLimitRejectedReason))) + l.ctx.appendSubgraphErrors(res.err, NewRateLimitError(res.ds.Name, fetchItem.ResponsePath, res.rateLimitRejectedReason)) pathPart := l.renderAtPathErrorPart(fetchItem.ResponsePath) var ( err error diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index c94dbff834..7b57249dd9 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - goerrors "errors" "fmt" "io" "strconv" @@ -760,8 +759,8 @@ func (r *Resolvable) addRejectFieldError(reason string, ds DataSourceInfo, field } else { errorMessage = fmt.Sprintf("Unauthorized to load field '%s', Reason: %s.", fieldPath, reason) } - r.ctx.appendSubgraphError(goerrors.Join(errors.New(errorMessage), - NewSubgraphError(ds, fieldPath, reason, 0))) + r.ctx.appendSubgraphErrors(errors.New(errorMessage), + NewSubgraphError(ds, fieldPath, reason, 0)) fastjsonext.AppendErrorWithExtensionsCodeToArray(r.astjsonArena, r.errors, errorMessage, errorcodes.UnauthorizedFieldOrType, r.path) r.popNodePathElement(nodePath) }