Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(backend,shared): Include clerkTraceId for backend api errors #1986

Merged
merged 1 commit into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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