Repository navigation
feat(commerce-server): standardize route error handling - #1494
anam-godaddy wants to merge 16 commits into
Conversation
Invalid order IDs (blank, padded, `.`, `..`) and missing orders previously surfaced as 500s, indistinguishable from credential or upstream failures. - getOrderStatus throws InvalidOrderIdError for invalid IDs and OrderNotFoundError when the Orders API returns 404 or the order is bound to another order ID, store, or channel. Incomplete responses stay generic. - The order-status route maps these to 400 and 404; everything else is 500. - Padded IDs are rejected rather than trimmed so the lookup never targets a different ID than the caller supplied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 500 body echoed the internal error message, which can name upstream
HTTP statuses, OAuth/token failures, or configuration problems. Return only
{ success: false, error } and log the detail with console.error.
Export ORDER_STATUS_UNKNOWN so callers compare against a constant rather than
the 'unknown' literal; status stays a string because the Orders API's full
paymentStatus set is not documented here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nses Ten catalog, cart, and checkout routes returned the raw upstream error message in their 500 bodies. They now respond with only the generic error label and log the detail server-side via a shared respondWithFailure helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 34f0279 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Every route now fails through one path: domain code and upstream adapters
throw typed CommerceError subclasses, and commerceRoute turns them into
{ error, code, correlationId } responses with an X-Correlation-Id header.
- 502 for Commerce failures, including rejected OAuth client credentials
(upstream_unauthorized). These concern the server's client_credentials
token, so they are never reported to the caller as 401/403.
- 503 for missing or unreadable configuration on every route.
- 404 for cart writes against a missing or completed cart, via the existing
cart-not-found classifier moved into an upstream mapping table.
- 500 only for unexpected errors; detail goes to a host-configurable logger.
Hosts can pass logger and getCorrelationId to the router factories.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The header name was not an established convention in this repo or in the Commerce APIs. Keep the id in failure bodies and logs only; a response header can be added once its name is designed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The id identifies one HTTP request, so name it requestId. Renames the body field, the getRequestId router option, CommerceRequestIdResolver, and the log context field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cblock Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
No P0/P1 blockers found. I found two P2 items worth fixing:
Verification:
Reviewed commit: |
… host logger - GraphQLErrorWithCodes now reports upstream_unauthorized when any error carries a 401/403 status (extensions.status / extensions.http.status), not only when the HTTP response status is 401/403. - sendCommerceError catches a throwing host logger, falls back to console.error with both errors, and still sends the standard body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks — both confirmed and fixed in eb36409.
All four new tests fail without the fixes. Full suite: typecheck, lint, and 208 tests pass, including the socket-based integration tests your environment couldn't run. |
…lures An Orders API response whose context lacks storeId or channelId fell through to the store/channel mismatch check and was reported as 404. Require both binding fields before comparing them so malformed responses keep the generic 500 and server-side logging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pbennett1-godaddy
left a comment
There was a problem hiding this comment.
Review notes inline. The first four comments (request ID resolver, bare 404 in order lookup, checkout stale-cart status, cart rule scope) affect the error contract and are worth addressing before merge. The rest are smaller.
|
|
||
| router.use((_req, res, next): void => { | ||
| router.use((req, res, next): void => { | ||
| const requestId: string = resolveRequestId(req, options.getRequestId); |
There was a problem hiding this comment.
The host's getRequestId runs outside any try/catch. If it throws (e.g. req.get('x-request-id')!.trim() with the header missing), Express's default handler sends an HTML 500, with no { error, code, requestId } body and no logger call. Suggest catching and falling back to randomUUID().
A smaller related point: this resolves eagerly on every request, while requestIdFor in commerce-route.ts has its own lazy fallback that ignores the resolver. Storing the resolver in res.locals and resolving lazily in one place would remove the split and also cover routes mounted outside the router.
There was a problem hiding this comment.
Done in 34f0279. The router now stores the host resolver in res.locals instead of resolving eagerly, and requestIdFor resolves lazily in one place, at most once per request and only when a failure body needs an id. If getRequestId throws, it falls back to randomUUID(), so the standard { error, code, requestId } body is still sent. Tests cover a throwing resolver and assert the resolver isn't called on a successful request.
| } catch (cause) { | ||
| throw new UpstreamError('Order lookup could not reach Commerce', { cause }); | ||
| } | ||
| if (response.status === 404) throw new OrderNotFoundError(); |
There was a problem hiding this comment.
isCartNotFoundError deliberately excludes bare 404s because "a bare HTTP 404 can also mean the upstream endpoint itself is unavailable", but here every 404 becomes OrderNotFoundError. A wrong storeId or an apiBaseUrl that doesn't serve the Orders route would make every lookup return 404 not_found, and since only 5xx is logged, nothing would be recorded. Could this require evidence of an actual missing order (body/error code), and otherwise throw UpstreamError?
There was a problem hiding this comment.
Addressed in #1488 (c43083d) and merged here in 897cf6d. After checking order-api's REST handler, only a 404 whose body is { code: 'NOT_FOUND' } maps to OrderNotFoundError. Any other 404 (e.g. Express's HTML "Cannot GET" for an unrouted path, or another base URL's 404) throws UpstreamError, so it's a logged 502. A wrong storeId gets a 403 from order-api's authorization check, which was already a logged 502. Details: #1488 (comment)
| } catch (error) { | ||
| throw new Error('Commerce checkout session could not be created', { cause: error }); | ||
| // Not classified further: the cart-not-found rule is evidenced for the order storefront only. | ||
| throw new UpstreamError('Commerce checkout session could not be created', { |
There was a problem hiding this comment.
I see the comment explaining why this isn't classified. The result is that checkout with a stale or completed draftOrderId (e.g. already paid in another tab) returns 502 upstream_error. The README's 404 row says "a cart write against a missing, expired, or completed cart" returns not_found, which checkout is arguably part of, and the client can't tell this case apart from an outage. Either classify it (if checkout-api's response for this is known) or narrow the README wording to say checkout is excluded.
There was a problem hiding this comment.
Went with narrowing the docs in 34f0279. We don't have evidence of what checkout-api returns for a stale or completed draftOrderId, so classifying it would break the mapping table's evidence rule. The README's 404 row now covers writes to an existing cart (/cart/:id/...) only and says checkout with a stale draft returns 502; the changeset says the same. Happy to add a rule once we capture a real response.
| const UPSTREAM_ERROR_RULES: readonly UpstreamErrorRule[] = [ | ||
| { | ||
| // A completed (paid) draft is reported the same way, so this is "no longer a usable cart". | ||
| matches: isCartNotFoundError, |
There was a problem hiding this comment.
This rule applies to every route that goes through commerceRoute, including POST /cart (create) and the catalog routes. For example, if addDraftOrder succeeds and the follow-up addLineItemBySkuId returns INTERNAL_SERVER_ERROR / "Order not found" (eventual consistency), the create request returns 404 "Cart not found" and leaves an orphaned cart. Should this rule be scoped to routes with a :id cart path parameter?
There was a problem hiding this comment.
Agreed, done in 34f0279. commerceRoute takes a per-route classifyUpstreamError option, and only the five /cart/:id routes pass classifyCartUpstreamError. POST /cart and the catalog routes now return 502 upstream_error for "Order not found". Tests cover create (failing on the first item add after addDraftOrder succeeds) and products.
| if (!response.ok) { | ||
| throw new Error(`Failed to get access token: ${response.status} ${response.statusText}`); | ||
| throw new UpstreamError(`Failed to get access token: ${response.status} ${response.statusText}`, { | ||
| unauthorized: response.status === 400 || response.status === 401 || response.status === 403, |
There was a problem hiding this comment.
RFC 6749 uses 400 for invalid_request, unsupported_grant_type, etc., not just invalid_client/invalid_grant. Treating every 400 as upstream_unauthorized could send operators rotating valid credentials when the actual problem is the request shape. Consider parsing the OAuth error field and only flagging invalid_client/invalid_grant/unauthorized_client as unauthorized.
There was a problem hiding this comment.
Done in 34f0279. For a 400, the token request reads the OAuth error field. Only invalid_client, invalid_grant, unauthorized_client, and invalid_scope are upstream_unauthorized. I kept invalid_scope because it means the client lacks the scope it requested (e.g. commerce.order:read), which is a credentials/grant problem for operators. invalid_request, other codes, and non-JSON 400s are upstream_error. The error value is also logged as details.oauthError.
| return read(); | ||
| } catch (cause) { | ||
| if (cause instanceof CommerceError) throw cause; | ||
| throw new CommerceNotConfiguredError('Commerce configuration could not be read', { cause }); |
There was a problem hiding this comment.
This wrapping only happens through commerceConfigurationForResponse. The in-process getOrderStatus() (get-order-status.ts:73) and createCheckoutSession() (create-checkout-session.ts:190) call configuration.read() directly, so a host config that throws a plain Error reaches in-process callers unwrapped. The README says "In-process helpers throw the exported CommerceError subclasses". Applying readOrNotConfigured in those helpers too, or at a shared configuration boundary, would make that true for both paths.
There was a problem hiding this comment.
Done in 34f0279. The wrapper is now guardCommerceConfiguration, used by commerceConfigurationForResponse and by getOrderStatus and createCheckoutSession directly, so a host configuration that throws a plain error is CommerceNotConfiguredError (503 not_configured, original error as cause) for in-process callers too.
| async function readOrderStatus(req: Request, res: Response): Promise<void> { | ||
| const { orderId } = req.query; | ||
| if (!orderId || typeof orderId !== 'string') { | ||
| throw new InvalidOrderIdError(); |
There was a problem hiding this comment.
nit: getOrderStatus now fully validates the ID (missing, blank, padded, ., ..) and throws InvalidOrderIdError. Only the non-string check is unique to the route. Narrowing it to typeof orderId !== 'string' gives a single source of truth.
| }, | ||
| ); | ||
| } catch (cause) { | ||
| throw new UpstreamError('Order lookup could not reach Commerce', { cause }); |
There was a problem hiding this comment.
nit: this fetch to UpstreamError and JSON-parse to UpstreamError wrapping is the third copy, after getOAuthAccessToken and gqlRequest, and the copies differ in which details they include (endpoint/scope). A shared fetchUpstream/readUpstreamJson helper would keep classification consistent.
There was a problem hiding this comment.
Agreed it's duplication, but I've left the shared fetchUpstream/readUpstreamJson helper for a follow-up to keep this PR's scope contained. For the inconsistency you pointed out, the order lookup now logs endpoint with every upstream failure (plus the Orders API error code on 404/422), matching what gqlRequest logs (34f0279).
| return requestId; | ||
| } | ||
|
|
||
| function loggerFor(res: Response): CommerceLogger { |
There was a problem hiding this comment.
nit: the router always installs a validated logger (options.logger ?? consoleCommerceLogger), so duck-typing res.locals.commerceLogger on every 5xx seems unnecessary. Typing res.locals or passing the logger through commerceRoute would remove the runtime check.
There was a problem hiding this comment.
Done in 34f0279. The router's res.locals entries are typed (CommerceObservabilityLocals) and the runtime shape check is gone. Routes mounted outside the router still fall back to console.error, and the existing try/catch around the logger call covers a host logger that throws.
- Treat an upstream order ID mismatch or non-string ID as a malformed response (500 + log) instead of not found. - Cancel unread error response bodies and log upstream 404s. - Match route errors by name so they survive duplicate package copies. - Leave string ID validation to getOrderStatus. - Restore mocks in afterEach. - Bump the changeset to minor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ror code The Orders API reports a missing order as 404 with code NOT_FOUND and an undecodable order ID as 422 with code VALIDATION_FAILED. Only those map to OrderNotFoundError and InvalidOrderIdError; any other 404, such as an unrouted path or misconfigured base URL, is an upstream failure (logged 500). This replaces the console.warn on every upstream 404. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rror-bodies Brings in godaddy#1488's review fixes and main. Conflicts resolved toward this branch's error types: - get-order-status: godaddy#1488's classification (Orders API NOT_FOUND -> 404, VALIDATION_FAILED -> 400, incomplete/mismatched order or any other 404 -> malformed) now throws UpstreamError (502) instead of a plain Error. - order-status GET: keeps commerceRoute, with godaddy#1488's narrowed `typeof orderId !== 'string'` check. The name-based error check and its "another copy" test are dropped; the route and lib share one module. - README and tests: godaddy#1488's cases expressed as 502/`code` bodies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Scope the cart-not-found rule to /cart/:id routes via a per-route classifyUpstreamError option. Creating a cart and catalog routes no longer turn "Order not found" into 404 "Cart not found". - Resolve the request id lazily, once, from the resolver stored in res.locals, and fall back to a UUID when the host's getRequestId throws. Type the router's res.locals instead of duck-typing the logger. - Classify OAuth 400s by the RFC 6749 `error` field. Only invalid_client, invalid_grant, unauthorized_client, and invalid_scope are upstream_unauthorized; other 400s are upstream_error. - Wrap configuration reads in getOrderStatus and createCheckoutSession so a throwing host configuration is CommerceNotConfiguredError in-process too. - Log the order lookup endpoint and Orders API error code with its upstream failures, matching gqlRequest's details. - Docs: checkout with a stale draftOrderId stays 502; in-process callers check error.code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Important
Depends on #1488 — merge that first. This branch contains #1488's commits, so the diff against
mainincludes them until #1488 merges. To review only this PR's changes, look at its non-merge commits (8aecdf2onward); merge commits6d4846eand897cf6dbring in #1488. After #1488 merges, this branch will be updated frommain.What changed
Error handling in
@godaddy/gd-commerce-serveris now one standard process instead of a try/catch per route.lib/commerce/errors.ts):CommerceError(httpStatus, code, message, { publicMessage, cause, details }), with subclassesInvalidRequestError,ScopeMismatchError,NotFoundError,CommerceNotConfiguredError, andUpstreamError.messageis internal; onlypublicMessage(or the route's label) reaches the browser.gqlRequest, the OAuth token request, and the Orders REST lookup turn network failures, non-2xx responses, non-JSON bodies, and GraphQL errors intoUpstreamError, keeping the upstream status and GraphQL codes indetailsfor the log.GraphQLErrorWithCodesnow extendsUpstreamErrorand reportsupstream_unauthorizedwhen the HTTP status or any error's embedded status is 401/403.config.tsthrowsCommerceNotConfiguredError, and host-supplied configurations that throw plain errors are also reported as not configured, both through the routes and whengetOrderStatus()/createCheckoutSession()are called in-process.lib/commerce/upstream-errors.ts): rules that turn a specific upstream signal into a more precise error. It holds one rule, the existing cart-not-found classifier, moved out ofcart/[id]/GET.ts. Routes opt in throughcommerceRoute'sclassifyUpstreamErroroption; only the/cart/:idroutes use the cart rule, so creating a cart and the catalog routes never report "Cart not found". New rules need evidence and a test.commerceRoute(label, handler)/sendCommerceError): every route's catch-all is replaced. It picks the status, writes{ error, code, requestId }(order-status keepssuccess: false), and logs 5xx detail with the same id. 4xx responses are not logged.logger(defaults toconsole.error) andgetRequestId(req)oncreateCommerceRouter, and as the last argument ofcreateCommerceCatalogRouter/createGoDaddyPaymentsRouter. The id is resolved only when a failure body needs it. A host id is used only if it matches[A-Za-z0-9._:-]{1,128}; otherwise, or ifgetRequestIdthrows, a UUID is generated. No response header is set yet; its name should be chosen once it is known which request-id header the GoDaddy edge and Commerce APIs use.Status codes
codeinvalid_requestnot_foundNOT_FOUND) or product; write to an existing cart (/cart/:id/...) that is missing, expired, or completedscope_mismatchX-Commerce-Scopeupstream_unauthorizedinvalid_client/invalid_grant/unauthorized_client/invalid_scope)upstream_errornot_configured/configand for checkout's missing return URLs; 500 elsewhereinternal_errorGET /cart/:idkeeps returning200 { cart: null }for a missing cart, as the storefront contract documents.Why token failures are 502, not 401/403
The token is the server's own
client_credentialsgrant (getOAuthAccessToken); browsers send no token. A 401/403 would blame the caller and can trigger host logic for an expired session (sign-out, login redirect).code: upstream_unauthorized, together with the logged upstream status and requested scope, identifies the problem without that side effect. OAuth 400 responses are classified the same way only when their RFC 6749 §5.2errorisinvalid_client,invalid_grant,unauthorized_client, orinvalid_scope; other 400s (e.g.invalid_request) areupstream_error, so a malformed request doesn't send operators rotating valid credentials.Limits
upstreamCodesandupstreamStatusare the evidence for future rules.draftOrderIdtherefore returns 502, not 404.Relationship to #1488
This branch merges #1488's branch so it can cover order-status; the diff includes #1488's commits until #1488 merges. All of #1488's review fixes are merged (897cf6d), expressed with this branch's error types:
code: NOT_FOUNDisOrderNotFoundError(404); a 422VALIDATION_FAILEDisInvalidOrderIdError(400).UpstreamError(502). fix(commerce-server): return 400/404 from order-status instead of 500 #1488 reports these as 500.error.namecheck is not carried over: the route goes throughcommerceRoute, which shares a module with the errors it maps. In-process callers should checkerror.code, which holds across duplicate package copies.#1488's
InvalidOrderIdErrorandOrderNotFoundErrorbecome subclasses ofInvalidRequestErrorandNotFoundError; their bodies gaincodeandrequestId.Compatibility
messageremoved from failure bodies. The bundled storefront reads onlyerror(commerce-storefront/src/api.ts).code.codeandrequestIdin failure bodies.minorbecause response codes and bodies change. App Builder pins^0.1.1, so it won't pick this up until its range is bumped. fix(commerce-server): return 400/404 from order-status instead of 500 #1488's changeset is alsominor, so neither reaches App Builder until its range is bumped.Testing
pnpm --filter @godaddy/gd-commerce-server typecheck,lint,test— 233 passedinternal_error; upstream errors → 502 with no upstream text in the body and upstream codes in the log; cart writes on a missing cart → 404; an upstream GraphQL 401 →upstream_unauthorized, including a 401/403 embedded in an HTTP 200 body (extensions.status/extensions.http.status)console.errorfallback; safe host ids are used and unsafe ones replaced; unreadable configuration → 503NOT_FOUND→ 404,VALIDATION_FAILED→ 400, any other 404 → 502; token denied → 502upstream_unauthorized; OAuth 400invalid_request→ 502upstream_error; not configured → 503, including in-processgetRequestIdstill yields the standard body with a UUID; the resolver isn't called on success🤖 Generated with Claude Code