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
…1986)

clerkTraceId is used when available and defaults to cloudflares CF Ray id
when its missing.
  • Loading branch information
Nikpolik authored Oct 31, 2023
1 parent a605335 commit f5d55bb
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 f5d55bb

Please sign in to comment.