@shipfox/api-integration-github 12.3.0 → 12.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-github",
3
3
  "license": "MIT",
4
- "version": "12.3.0",
4
+ "version": "12.5.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -28,7 +28,7 @@
28
28
  "octokit": "^5.0.3",
29
29
  "zod": "^4.4.3",
30
30
  "@shipfox/api-auth-context": "12.2.0",
31
- "@shipfox/api-integration-spi": "1.1.0",
31
+ "@shipfox/api-integration-spi": "1.1.1",
32
32
  "@shipfox/api-integration-github-dto": "12.2.0",
33
33
  "@shipfox/config": "1.2.4",
34
34
  "@shipfox/node-drizzle": "0.3.5",
@@ -1,9 +1,19 @@
1
1
  import {GithubIntegrationProviderError} from '#core/errors.js';
2
- import {createGithubApiClient} from './client.js';
2
+ import {createGithubApiClient, mapGithubError} from './client.js';
3
3
 
4
- const {createInstallationAccessTokenMock} = vi.hoisted(() => ({
5
- createInstallationAccessTokenMock: vi.fn(),
6
- }));
4
+ const {createInstallationAccessTokenMock, RequestErrorMock} = vi.hoisted(() => {
5
+ class RequestErrorMock extends Error {
6
+ constructor(
7
+ message: string,
8
+ public readonly status: number,
9
+ ) {
10
+ super(message);
11
+ this.name = 'HttpError';
12
+ }
13
+ }
14
+
15
+ return {createInstallationAccessTokenMock: vi.fn(), RequestErrorMock};
16
+ });
7
17
 
8
18
  vi.mock('octokit', () => ({
9
19
  App: class App {
@@ -16,9 +26,35 @@ vi.mock('octokit', () => ({
16
26
  return {defaults: options};
17
27
  },
18
28
  },
19
- RequestError: class RequestError extends Error {},
29
+ RequestError: RequestErrorMock,
20
30
  }));
21
31
 
32
+ describe('mapGithubError', () => {
33
+ it.each([400, 409, 422])('maps HTTP %i to a terminal provider rejection', async (status) => {
34
+ const error = new RequestErrorMock(`GitHub rejected request with HTTP ${status}`, status);
35
+
36
+ const result = mapGithubError(() => Promise.reject(error));
37
+
38
+ await expect(result).rejects.toMatchObject({
39
+ reason: 'provider-rejected',
40
+ message: `GitHub rejected request with HTTP ${status}`,
41
+ status,
42
+ });
43
+ });
44
+
45
+ it('preserves the status when mapping provider unavailability', async () => {
46
+ const error = new RequestErrorMock('GitHub is unavailable', 503);
47
+
48
+ const result = mapGithubError(() => Promise.reject(error));
49
+
50
+ await expect(result).rejects.toMatchObject({
51
+ reason: 'provider-unavailable',
52
+ message: 'GitHub is unavailable',
53
+ status: 503,
54
+ });
55
+ });
56
+ });
57
+
22
58
  describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
23
59
  beforeEach(() => {
24
60
  createInstallationAccessTokenMock.mockReset();
package/src/api/client.ts CHANGED
@@ -433,13 +433,19 @@ export async function mapGithubError<T>(
433
433
  if (error instanceof GithubIntegrationProviderError) throw error;
434
434
  if (error instanceof RequestError) {
435
435
  if (error.status === 404) {
436
- throw new GithubIntegrationProviderError(notFoundReason, error.message);
436
+ throw new GithubIntegrationProviderError(
437
+ notFoundReason,
438
+ error.message,
439
+ undefined,
440
+ error.status,
441
+ );
437
442
  }
438
443
  if (error.status === 429 || isGithubRateLimitError(error)) {
439
444
  throw new GithubIntegrationProviderError(
440
445
  'rate-limited',
441
446
  error.message,
442
447
  retryAfterSeconds(error),
448
+ error.status,
443
449
  );
444
450
  }
445
451
  if (error.status === 401 || error.status === 403) {
@@ -447,10 +453,24 @@ export async function mapGithubError<T>(
447
453
  'access-denied',
448
454
  error.message,
449
455
  retryAfterSeconds(error),
456
+ error.status,
450
457
  );
451
458
  }
452
459
  if (error.status >= 500) {
453
- throw new GithubIntegrationProviderError('provider-unavailable', error.message);
460
+ throw new GithubIntegrationProviderError(
461
+ 'provider-unavailable',
462
+ error.message,
463
+ undefined,
464
+ error.status,
465
+ );
466
+ }
467
+ if (error.status >= 400) {
468
+ throw new GithubIntegrationProviderError(
469
+ 'provider-rejected',
470
+ error.message,
471
+ undefined,
472
+ error.status,
473
+ );
454
474
  }
455
475
  }
456
476
  if (error instanceof Error && error.name === 'AbortError') {
@@ -19,15 +19,21 @@ const providerErrorReasons = [
19
19
  'rate-limited',
20
20
  'timeout',
21
21
  'provider-unavailable',
22
+ 'provider-rejected',
22
23
  'malformed-provider-response',
23
24
  'content-too-large',
24
25
  'too-many-files',
25
26
  ] as const satisfies readonly IntegrationProviderErrorReason[];
26
27
 
27
28
  const providerErrorReasonSchema = z.enum(providerErrorReasons);
29
+ const backoffErrorSchema = z.object({
30
+ message: z.string(),
31
+ status: z.number().int().optional(),
32
+ });
28
33
  const terminalMintErrorReasons = new Set<IntegrationProviderErrorReason>([
29
34
  'access-denied',
30
35
  'installation-not-found',
36
+ 'provider-rejected',
31
37
  'malformed-provider-response',
32
38
  ]);
33
39
 
@@ -44,6 +50,7 @@ const installationTokenEnvelopeSchema = z.object({
44
50
  permissions: z.record(z.string(), z.enum(['read', 'write', 'admin'])).optional(),
45
51
  backoffUntil: z.string().datetime().optional(),
46
52
  backoffReason: providerErrorReasonSchema.optional(),
53
+ backoffError: backoffErrorSchema.optional(),
47
54
  });
48
55
 
49
56
  export interface InstallationTokenEnvelope {
@@ -52,6 +59,12 @@ export interface InstallationTokenEnvelope {
52
59
  permissions?: Record<string, 'read' | 'write' | 'admin'> | undefined;
53
60
  backoffUntil?: Date | undefined;
54
61
  backoffReason?: IntegrationProviderErrorReason | undefined;
62
+ backoffError?:
63
+ | {
64
+ message: string;
65
+ status?: number | undefined;
66
+ }
67
+ | undefined;
55
68
  }
56
69
 
57
70
  export type MintErrorClass = 'transient' | 'terminal';
@@ -75,6 +88,7 @@ export function encodeInstallationTokenEnvelope(envelope: InstallationTokenEnvel
75
88
  backoffUntil: envelope.backoffUntil.toISOString(),
76
89
  }),
77
90
  ...(envelope.backoffReason !== undefined && {backoffReason: envelope.backoffReason}),
91
+ ...(envelope.backoffError !== undefined && {backoffError: envelope.backoffError}),
78
92
  });
79
93
  }
80
94
 
@@ -95,6 +109,7 @@ export function parseInstallationTokenEnvelope(raw: string): InstallationTokenEn
95
109
  permissions: result.data.permissions,
96
110
  backoffUntil: result.data.backoffUntil ? new Date(result.data.backoffUntil) : undefined,
97
111
  backoffReason: result.data.backoffReason,
112
+ backoffError: result.data.backoffError,
98
113
  };
99
114
  }
100
115
 
@@ -151,18 +166,25 @@ export function backoffMs(classified: ClassifiedMintError): number {
151
166
  export function providerErrorFromBackoff(
152
167
  reason: IntegrationProviderErrorReason,
153
168
  retryAfterMs: number,
169
+ backoffError?: InstallationTokenEnvelope['backoffError'],
154
170
  ): GithubIntegrationProviderError {
155
171
  return new GithubIntegrationProviderError(
156
172
  reason,
157
- `GitHub installation token mint is backed off after ${reason}`,
173
+ backoffError?.message ?? `GitHub installation token mint is backed off after ${reason}`,
158
174
  Math.max(1, Math.ceil(retryAfterMs / 1000)),
175
+ backoffError?.status,
159
176
  );
160
177
  }
161
178
 
162
179
  export function toProviderError(error: unknown): GithubIntegrationProviderError {
163
180
  if (error instanceof GithubIntegrationProviderError) return error;
164
181
  if (error instanceof IntegrationProviderError) {
165
- return new GithubIntegrationProviderError(error.reason, error.message, error.retryAfterSeconds);
182
+ return new GithubIntegrationProviderError(
183
+ error.reason,
184
+ error.message,
185
+ error.retryAfterSeconds,
186
+ error.status,
187
+ );
166
188
  }
167
189
  return new GithubIntegrationProviderError(
168
190
  'provider-unavailable',
@@ -190,6 +190,34 @@ describe('SharedInstallationTokenCache', () => {
190
190
  expect(mint).toHaveBeenCalledTimes(1);
191
191
  });
192
192
 
193
+ it('preserves terminal provider rejection details through backoff', async () => {
194
+ const store = createStore();
195
+ const mint = vi
196
+ .fn()
197
+ .mockRejectedValue(
198
+ new GithubIntegrationProviderError(
199
+ 'provider-rejected',
200
+ 'commit_id is missing',
201
+ undefined,
202
+ 422,
203
+ ),
204
+ );
205
+ const shared = cache({store});
206
+
207
+ await expect(shared.getOrMint(installationId, mint)).rejects.toMatchObject({
208
+ reason: 'provider-rejected',
209
+ message: 'commit_id is missing',
210
+ status: 422,
211
+ });
212
+ await expect(shared.getOrMint(installationId, mint)).rejects.toMatchObject({
213
+ reason: 'provider-rejected',
214
+ message: 'commit_id is missing',
215
+ status: 422,
216
+ });
217
+
218
+ expect(mint).toHaveBeenCalledTimes(1);
219
+ });
220
+
193
221
  it('serves stale when refresh minting fails while the token is still valid', async () => {
194
222
  const store = createStore();
195
223
  setEnvelope(store, token('ghs_existing', '2026-06-10T11:04:30.000Z'));
@@ -113,6 +113,7 @@ export class SharedInstallationTokenCache implements InstallationTokenCache {
113
113
  throw providerErrorFromBackoff(
114
114
  envelope?.backoffReason ?? 'provider-unavailable',
115
115
  (envelope?.backoffUntil?.getTime() ?? now.getTime()) - now.getTime(),
116
+ envelope?.backoffError,
116
117
  );
117
118
  }
118
119
 
@@ -131,6 +132,10 @@ export class SharedInstallationTokenCache implements InstallationTokenCache {
131
132
  permissions: envelope?.permissions,
132
133
  backoffUntil: until,
133
134
  backoffReason: classified.reason,
135
+ backoffError: {
136
+ message: providerError.message,
137
+ ...(providerError.status === undefined ? {} : {status: providerError.status}),
138
+ },
134
139
  }).catch((writeError) => {
135
140
  logger().warn(
136
141
  {installationId: params.installationId, reason: classified.reason, error: writeError},
@@ -215,6 +220,7 @@ export class SharedInstallationTokenCache implements InstallationTokenCache {
215
220
  throw providerErrorFromBackoff(
216
221
  params.envelope.backoffReason,
217
222
  params.envelope.backoffUntil.getTime() - initialNow.getTime(),
223
+ params.envelope.backoffError,
218
224
  );
219
225
  }
220
226
 
@@ -231,6 +237,7 @@ export class SharedInstallationTokenCache implements InstallationTokenCache {
231
237
  throw providerErrorFromBackoff(
232
238
  envelope?.backoffReason ?? 'provider-unavailable',
233
239
  (envelope?.backoffUntil?.getTime() ?? now.getTime()) - now.getTime(),
240
+ envelope?.backoffError,
234
241
  );
235
242
  }
236
243
  }