Skip to content

Commit 4ef4dc8

Browse files
refactor(worker): normalize HTTP error metadata access
1 parent 2e4e0a3 commit 4ef4dc8

3 files changed

Lines changed: 101 additions & 41 deletions

File tree

packages/backend/src/ee/permissionSyncError.ts

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { IdentityProviderType } from '@sourcebot/shared';
2-
import { getErrorStatus } from '../errors.js';
2+
import { getErrorHeader, getErrorStatus } from '../errors.js';
33

44
export type PermissionSyncUpstreamErrorKind =
55
| 'credential_rejected'
@@ -38,31 +38,10 @@ export class PermissionSyncUpstreamError extends Error {
3838
}
3939
}
4040

41-
const getHeader = (error: unknown, name: string): string | undefined => {
42-
if (error === null || typeof error !== 'object') {
43-
return undefined;
44-
}
45-
46-
const directHeaders = (error as { response?: { headers?: unknown } }).response?.headers;
47-
const nestedHeaders = (error as { cause?: { response?: { headers?: unknown } } }).cause?.response?.headers;
48-
const headers = directHeaders ?? nestedHeaders;
49-
50-
if (headers instanceof Headers) {
51-
return headers.get(name) ?? undefined;
52-
}
53-
54-
if (headers !== null && typeof headers === 'object') {
55-
const value = (headers as Record<string, unknown>)[name.toLowerCase()];
56-
return typeof value === 'string' ? value : undefined;
57-
}
58-
59-
return undefined;
60-
};
61-
6241
const isRateLimited = (error: unknown, status: number | null): boolean =>
6342
status === 429 ||
64-
getHeader(error, 'retry-after') !== undefined ||
65-
getHeader(error, 'x-ratelimit-remaining') === '0';
43+
getErrorHeader(error, 'retry-after') !== undefined ||
44+
getErrorHeader(error, 'x-ratelimit-remaining') === '0';
6645

6746
const isNetworkOrTimeoutError = (error: unknown): boolean => {
6847
if (!(error instanceof Error)) {

packages/backend/src/errors.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, test } from 'vitest';
22
import { RequestError } from '@octokit/request-error';
33
import { GitbeakerRequestError } from '@gitbeaker/requester-utils';
4-
import { isForbidden, isGone, isUnauthorized } from './errors';
4+
import { getErrorHeader, getErrorStatus, isForbidden, isGone, isUnauthorized } from './errors';
55
import { throwOnHttpError } from './bitbucket';
66

77
// Helper: invoke the openapi-fetch middleware against a synthetic Response and
@@ -23,6 +23,59 @@ const invokeMiddleware = async (response: Response): Promise<unknown> => {
2323
}
2424
};
2525

26+
describe('HTTP error metadata', () => {
27+
test('reads status and case-insensitive headers from a direct response', () => {
28+
const error = Object.assign(new Error('Rate limited'), {
29+
response: {
30+
status: 429,
31+
headers: {
32+
'Retry-After': '30',
33+
},
34+
},
35+
});
36+
37+
expect(getErrorStatus(error)).toBe(429);
38+
expect(getErrorHeader(error, 'retry-after')).toBe('30');
39+
});
40+
41+
test('reads status and native Headers from a nested cause response', () => {
42+
const error = Object.assign(new Error('Rate limited'), {
43+
cause: {
44+
response: new Response(null, {
45+
status: 429,
46+
headers: {
47+
'X-RateLimit-Remaining': '0',
48+
},
49+
}),
50+
},
51+
});
52+
53+
expect(getErrorStatus(error)).toBe(429);
54+
expect(getErrorHeader(error, 'x-ratelimit-remaining')).toBe('0');
55+
});
56+
57+
test('combines a direct status with headers from the direct response', () => {
58+
const error = Object.assign(new Error('Rate limited'), {
59+
status: 403,
60+
response: {
61+
headers: {
62+
'x-ratelimit-remaining': '0',
63+
},
64+
},
65+
});
66+
67+
expect(getErrorStatus(error)).toBe(403);
68+
expect(getErrorHeader(error, 'X-RateLimit-Remaining')).toBe('0');
69+
});
70+
71+
test('returns no metadata for a plain error', () => {
72+
const error = new Error('Not an HTTP error');
73+
74+
expect(getErrorStatus(error)).toBeNull();
75+
expect(getErrorHeader(error, 'retry-after')).toBeUndefined();
76+
});
77+
});
78+
2679
describe('isUnauthorized', () => {
2780
test('Octokit RequestError with status 401', () => {
2881
const err = new RequestError('Unauthorized', 401, {

packages/backend/src/errors.ts

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,56 @@
11

2+
type HttpErrorDetails = {
3+
status: number | null;
4+
headers?: unknown;
5+
};
6+
27
/**
3-
* Extract an HTTP status code from a thrown error across the libraries used by
4-
* the code-host clients:
5-
* - Octokit RequestError: { status }
6-
* - openapi-fetch (Bitbucket Cloud / Server): direct throws with { status }
7-
* or errors wrapped via Object.assign(new Error(...), { status })
8-
* - gitbeaker (GitLab): { cause: { response: { status } } }
8+
* Normalizes HTTP response metadata across the code-host client libraries:
9+
* - Octokit RequestError: { status, response: { headers } }
10+
* - openapi-fetch (Bitbucket Cloud / Server): { status }
11+
* - gitbeaker (GitLab): { cause: { response: { status, headers } } }
912
*/
10-
export const getErrorStatus = (err: unknown): number | null => {
11-
if (err === null || typeof err !== 'object') {
12-
return null;
13+
const getHttpErrorDetails = (error: unknown): HttpErrorDetails => {
14+
if (error === null || typeof error !== 'object') {
15+
return { status: null };
1316
}
1417

15-
const direct = (err as { status?: unknown }).status;
16-
if (typeof direct === 'number') {
17-
return direct;
18+
const directError = error as {
19+
status?: unknown;
20+
response?: { status?: unknown; headers?: unknown };
21+
cause?: { response?: { status?: unknown; headers?: unknown } };
22+
};
23+
const directResponse = directError.response;
24+
const nestedResponse = directError.cause?.response;
25+
const status = [
26+
directError.status,
27+
directResponse?.status,
28+
nestedResponse?.status,
29+
].find((value): value is number => typeof value === 'number') ?? null;
30+
31+
return {
32+
status,
33+
headers: directResponse?.headers ?? nestedResponse?.headers,
34+
};
35+
};
36+
37+
export const getErrorStatus = (error: unknown): number | null =>
38+
getHttpErrorDetails(error).status;
39+
40+
export const getErrorHeader = (error: unknown, name: string): string | undefined => {
41+
const { headers } = getHttpErrorDetails(error);
42+
43+
if (headers instanceof Headers) {
44+
return headers.get(name) ?? undefined;
1845
}
1946

20-
const nested = (err as { cause?: { response?: { status?: unknown } } }).cause?.response?.status;
21-
if (typeof nested === 'number') {
22-
return nested;
47+
if (headers !== null && typeof headers === 'object') {
48+
const normalizedName = name.toLowerCase();
49+
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === normalizedName);
50+
return typeof entry?.[1] === 'string' ? entry[1] : undefined;
2351
}
2452

25-
return null;
53+
return undefined;
2654
};
2755

2856
export const isUnauthorized = (err: unknown): boolean => getErrorStatus(err) === 401;

0 commit comments

Comments
 (0)