Skip to content

Commit

Permalink
chore(backend,shared): Include clerkTraceId for backend api errors
Browse files Browse the repository at this point in the history
clerkTraceId is used when available and defaults to cloudflares CF Ray id
when its missing.
  • Loading branch information
Nikpolik committed Oct 30, 2023
1 parent 641de48 commit 1c04f6c
Show file tree
Hide file tree
Showing 5 changed files with 75 additions and 27 deletions.
7 changes: 7 additions & 0 deletions .changeset/sour-comics-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/backend': patch
'@clerk/shared': patch
---

Add clerkTraceId to ClerkBackendApiResponse and ClerkAPIResponseError to allow for better tracing and debugging API error responses.
Uses `clerk_trace_id` when available in a response and defaults to [`cf-ray` identifier](https://developers.cloudflare.com/fundamentals/reference/cloudflare-ray-id/) if missing.
30 changes: 28 additions & 2 deletions packages/backend/src/api/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import sinon from 'sinon';
import emailJson from '../fixtures/responses/email.json';
import userJson from '../fixtures/responses/user.json';
import runtime from '../runtime';
import { jsonNotOk, jsonOk } from '../util/mockFetch';
import { jsonError, jsonNotOk, jsonOk } from '../util/mockFetch';
import { createBackendApiClient } from './factory';

export default (QUnit: QUnit) => {
Expand Down Expand Up @@ -151,12 +151,14 @@ export default (QUnit: QUnit) => {

test('executes a failed backend API request and parses the error response', async assert => {
const mockErrorPayload = { code: 'whatever_error', message: 'whatever error', meta: {} };
const traceId = 'trace_id_123';
fakeFetch = sinon.stub(runtime, 'fetch');
fakeFetch.onCall(0).returns(jsonNotOk({ errors: [mockErrorPayload] }));
fakeFetch.onCall(0).returns(jsonNotOk({ errors: [mockErrorPayload], clerk_trace_id: traceId }));

try {
await apiClient.users.getUser('user_deadbeef');
} catch (e: any) {
assert.equal(e.clerkTraceId, traceId);
assert.equal(e.clerkError, true);
assert.equal(e.status, 422);
assert.equal(e.errors[0].code, 'whatever_error');
Expand All @@ -174,6 +176,30 @@ export default (QUnit: QUnit) => {
);
});

test('executes a failed backend API request and include cf ray id when trace not present', async assert => {
fakeFetch = sinon.stub(runtime, 'fetch');
fakeFetch.onCall(0).returns(jsonError({ errors: [] }));

try {
await apiClient.users.getUser('user_deadbeef');
} catch (e: any) {
assert.equal(e.clerkError, true);
assert.equal(e.status, 500);
assert.equal(e.clerkTraceId, 'mock_cf_ray');
}

assert.ok(
fakeFetch.calledOnceWith('https://api.clerk.test/v1/users/user_deadbeef', {
method: 'GET',
headers: {
Authorization: 'Bearer deadbeef',
'Content-Type': 'application/json',
'Clerk-Backend-SDK': '@clerk/backend',
},
}),
);
});

test('executes a successful backend API request to delete a domain', async assert => {
const domainId = 'dmn_123';
const fakeResponse = {
Expand Down
38 changes: 17 additions & 21 deletions packages/backend/src/api/request.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import type { ClerkAPIError, ClerkAPIErrorJSON } from '@clerk/types';
import deepmerge from 'deepmerge';
import snakecaseKeys from 'snakecase-keys';
Expand Down Expand Up @@ -37,6 +38,7 @@ export type ClerkBackendApiResponse<T> =
| {
data: null;
errors: ClerkAPIError[];
clerkTraceId?: string;
};

export type RequestFunction = ReturnType<typeof buildRequest>;
Expand All @@ -52,13 +54,14 @@ const withLegacyReturn =
(cb: any): LegacyRequestFunction =>
async (...args) => {
// @ts-ignore
const { data, errors, status, statusText } = await cb<T>(...args);
const { data, errors, status, statusText, clerkTraceId } = await cb<T>(...args);
if (errors === null) {
return data;
} else {
throw new ClerkAPIResponseError(statusText || '', {
data: errors,
status: status || '',
clerkTraceId,
});
}
};
Expand Down Expand Up @@ -161,6 +164,7 @@ export function buildRequest(options: CreateBackendApiOptions) {
message: err.message || 'Unexpected error',
},
],
clerkTraceId: getTraceId(err, res?.headers),
};
}

Expand All @@ -171,13 +175,25 @@ export function buildRequest(options: CreateBackendApiOptions) {
// @ts-expect-error
status: res?.status,
statusText: res?.statusText,
clerkTraceId: getTraceId(err, res?.headers),
};
}
};

return withLegacyReturn(request);
}

// Returns either clerk_trace_id if present in response json, otherwise defaults to CF-Ray header
// If the request failed before receiving a response, returns undefined
function getTraceId(data: unknown, headers?: Headers): string {
if (data && typeof data === 'object' && 'clerk_trace_id' in data && typeof data.clerk_trace_id === 'string') {
return data.clerk_trace_id;
}

const cfRay = headers?.get('cf-ray');
return cfRay || '';
}

function parseErrors(data: unknown): ClerkAPIError[] {
if (!!data && typeof data === 'object' && 'errors' in data) {
const errors = data.errors as ClerkAPIErrorJSON[];
Expand All @@ -197,23 +213,3 @@ function parseError(error: ClerkAPIErrorJSON): ClerkAPIError {
},
};
}

class ClerkAPIResponseError extends Error {
clerkError: true;

status: number;
message: string;

errors: ClerkAPIError[];

constructor(message: string, { data, status }: { data: ClerkAPIError[]; status: number }) {
super(message);

Object.setPrototypeOf(this, ClerkAPIResponseError.prototype);

this.clerkError = true;
this.message = message;
this.status = status;
this.errors = data;
}
}
12 changes: 11 additions & 1 deletion packages/backend/src/util/mockFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,14 @@ export function jsonError(body: unknown, status = 500) {
return Promise.resolve(mockResponse);
}

const mockHeadersGet = (key: string) => (key === constants.Headers.ContentType ? constants.ContentTypes.Json : null);
const mockHeadersGet = (key: string) => {
if (key === constants.Headers.ContentType) {
return constants.ContentTypes.Json;
}

if (key === 'cf-ray') {
return 'mock_cf_ray';
}

return null;
};
15 changes: 12 additions & 3 deletions packages/shared/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function isNetworkError(e: any): boolean {
interface ClerkAPIResponseOptions {
data: ClerkAPIErrorJSON[];
status: number;
clerkTraceId?: string;
}

// For a comprehensive Metamask error list, please see
Expand Down Expand Up @@ -88,24 +89,32 @@ export class ClerkAPIResponseError extends Error {

status: number;
message: string;
clerkTraceId?: string;

errors: ClerkAPIError[];

constructor(message: string, { data, status }: ClerkAPIResponseOptions) {
constructor(message: string, { data, status, clerkTraceId }: ClerkAPIResponseOptions) {
super(message);

Object.setPrototypeOf(this, ClerkAPIResponseError.prototype);

this.status = status;
this.message = message;
this.clerkTraceId = clerkTraceId;
this.clerkError = true;
this.errors = parseErrors(data);
}

public toString = () => {
return `[${this.name}]\nMessage:${this.message}\nStatus:${this.status}\nSerialized errors: ${this.errors.map(e =>
JSON.stringify(e),
let message = `[${this.name}]\nMessage:${this.message}\nStatus:${this.status}\nSerialized errors: ${this.errors.map(
e => JSON.stringify(e),
)}`;

if (this.clerkTraceId) {
message += `\nClerk Trace ID: ${this.clerkTraceId}`;
}

return message;
};
}

Expand Down

0 comments on commit 1c04f6c

Please sign in to comment.