Skip to content

Commit 0033a41

Browse files
authored
Merge pull request #1 from catloafsoft/codex/harden-network-error-reporting
[codex] harden network error reporting
2 parents 7fc431c + 0ea53f8 commit 0033a41

11 files changed

Lines changed: 413 additions & 64 deletions

File tree

.github/workflows/integration_tests.yml

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,9 @@ jobs:
2626
- name: Use Node.js
2727
uses: actions/setup-node@v4
2828
with:
29-
node-version: '20.x'
30-
31-
- name: Cache NPM # leverage npm cache on repeated workflow runs if package.json didn't change
32-
uses: actions/cache@v4
33-
with:
34-
path: ~/.npm
35-
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
36-
restore-keys: |
37-
${{ runner.os }}-node-
29+
node-version: '22.x'
30+
cache: 'yarn'
31+
cache-dependency-path: yarn.lock
3832
- name: Install Dependencies
3933
run: yarn
4034

.github/workflows/pr-checks.yml

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,9 @@ jobs:
1717
- name: Use Node.js
1818
uses: actions/setup-node@v4
1919
with:
20-
node-version: '20.x'
21-
22-
- name: Cache NPM # leverage npm cache on repeated workflow runs if package.json didn't change
23-
uses: actions/cache@v4
24-
with:
25-
path: ~/.npm
26-
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
27-
restore-keys: |
28-
${{ runner.os }}-node-
20+
node-version: '22.x'
21+
cache: 'yarn'
22+
cache-dependency-path: yarn.lock
2923
- name: Install Dependencies
3024
run: yarn
3125

@@ -44,15 +38,9 @@ jobs:
4438
- name: Use Node.js
4539
uses: actions/setup-node@v4
4640
with:
47-
node-version: '20.x'
48-
49-
- name: Cache NPM # leverage npm cache on repeated workflow runs if package.json didn't change
50-
uses: actions/cache@v4
51-
with:
52-
path: ~/.npm
53-
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
54-
restore-keys: |
55-
${{ runner.os }}-node-
41+
node-version: '22.x'
42+
cache: 'yarn'
43+
cache-dependency-path: yarn.lock
5644
- name: Install Dependencies
5745
run: yarn
5846

@@ -71,15 +59,9 @@ jobs:
7159
- name: Use Node.js
7260
uses: actions/setup-node@v4
7361
with:
74-
node-version: '20.x'
75-
76-
- name: Cache NPM
77-
uses: actions/cache@v4
78-
with:
79-
path: ~/.npm
80-
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
81-
restore-keys: |
82-
${{ runner.os }}-node-
62+
node-version: '22.x'
63+
cache: 'yarn'
64+
cache-dependency-path: yarn.lock
8365
- name: Install Dependencies
8466
run: yarn
8567

.github/workflows/publish.yml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,18 @@ jobs:
1212
- name: Check out code
1313
uses: actions/checkout@v4
1414

15-
- name: Build typescript
16-
run: |
17-
yarn
18-
yarn build
19-
2015
- name: Setup node for publishing
2116
uses: actions/setup-node@v4
2217
with:
23-
node-version: '20.x'
18+
node-version: '22.x'
2419
registry-url: 'https://registry.npmjs.org'
20+
cache: 'yarn'
21+
cache-dependency-path: yarn.lock
22+
23+
- name: Build typescript
24+
run: |
25+
yarn
26+
yarn build
2527
2628
- name: Publish to npm
2729
run: |

.yarnrc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
nodeLinker: node-modules

sdk/src/__tests__/internal/network/ApiInteractor.test.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,61 @@ describe('execute tests', () => {
154154
expect(ApiInteractorImpl.getErrorResponse).toBeCalledTimes(1);
155155
});
156156

157+
test('network client parse error with client response code is not retried', async () => {
158+
// given
159+
const expectedError = new QonversionError(
160+
QonversionErrorCode.BackendError,
161+
'Failed to parse JSON response',
162+
undefined,
163+
400,
164+
);
165+
networkClient.execute = jest.fn(() => {
166+
throw expectedError;
167+
});
168+
ApiInteractorImpl.getErrorResponse = savedGetErrorResponse;
169+
170+
// when
171+
const response = await apiInteractor.execute(request, new RetryPolicyExponential(3));
172+
173+
// then
174+
expect(response).toStrictEqual({
175+
code: 400,
176+
message: 'Failed to parse JSON response',
177+
isSuccess: false,
178+
});
179+
expect(networkClient.execute).toBeCalledTimes(1);
180+
expect(apiInteractor.prepareRetryConfig).not.toBeCalled();
181+
});
182+
183+
test('network client parse error with server response code is retried', async () => {
184+
// given
185+
const retryCount = 2;
186+
const expectedError = new QonversionError(
187+
QonversionErrorCode.BackendError,
188+
'Failed to parse JSON response',
189+
undefined,
190+
500,
191+
);
192+
networkClient.execute = jest.fn(() => {throw expectedError});
193+
ApiInteractorImpl.getErrorResponse = savedGetErrorResponse;
194+
apiInteractor.prepareRetryConfig = jest.fn((retryPolicy, attemptIndex) => ({
195+
attemptIndex: attemptIndex + 1,
196+
delay: 1,
197+
shouldRetry: attemptIndex < retryCount,
198+
}));
199+
200+
// when
201+
const response = await apiInteractor.execute(request, new RetryPolicyExponential(retryCount));
202+
203+
// then
204+
expect(response).toStrictEqual({
205+
code: 500,
206+
message: 'Failed to parse JSON response',
207+
isSuccess: false,
208+
});
209+
expect(networkClient.execute).toBeCalledTimes(retryCount + 1);
210+
});
211+
157212
test('retryable error response without retry config', async () => {
158213
// given
159214
testResponseCode = 555;
@@ -375,6 +430,67 @@ describe('getErrorResponse tests', () => {
375430
expect(result).toStrictEqual(expResult);
376431
});
377432

433+
test('get error from malformed api error object', () => {
434+
// given
435+
const networkResponse: RawNetworkResponse = {
436+
code: 502,
437+
payload: {},
438+
};
439+
440+
// when
441+
const result = ApiInteractorImpl.getErrorResponse(networkResponse)
442+
443+
// then
444+
expect(result).toStrictEqual({
445+
apiCode: undefined,
446+
code: 502,
447+
message: 'Unexpected API error response',
448+
type: undefined,
449+
isSuccess: false,
450+
});
451+
});
452+
453+
test('get error from plain text payload', () => {
454+
// given
455+
const networkResponse: RawNetworkResponse = {
456+
code: 503,
457+
payload: 'service unavailable',
458+
};
459+
460+
// when
461+
const result = ApiInteractorImpl.getErrorResponse(networkResponse)
462+
463+
// then
464+
expect(result).toStrictEqual({
465+
apiCode: undefined,
466+
code: 503,
467+
message: 'Unexpected API error response: service unavailable',
468+
type: undefined,
469+
isSuccess: false,
470+
});
471+
});
472+
473+
test('get error from long plain text payload', () => {
474+
// given
475+
const payload = 'x'.repeat(130);
476+
const networkResponse: RawNetworkResponse = {
477+
code: 503,
478+
payload,
479+
};
480+
481+
// when
482+
const result = ApiInteractorImpl.getErrorResponse(networkResponse)
483+
484+
// then
485+
expect(result).toStrictEqual({
486+
apiCode: undefined,
487+
code: 503,
488+
message: `Unexpected API error response: ${'x'.repeat(120)}...`,
489+
type: undefined,
490+
isSuccess: false,
491+
});
492+
});
493+
378494
test('get error from execution error', () => {
379495
// given
380496
const executionError = new Error('execution error');
@@ -385,7 +501,27 @@ describe('getErrorResponse tests', () => {
385501
}).toThrow(executionError);
386502
});
387503

388-
test('get error from execution error', () => {
504+
test('get error from execution error with response code', () => {
505+
// given
506+
const executionError = new QonversionError(
507+
QonversionErrorCode.BackendError,
508+
'Failed to parse JSON response',
509+
undefined,
510+
400,
511+
);
512+
513+
// when
514+
const result = ApiInteractorImpl.getErrorResponse(undefined, executionError);
515+
516+
// then
517+
expect(result).toStrictEqual({
518+
code: 400,
519+
message: 'Failed to parse JSON response',
520+
isSuccess: false,
521+
});
522+
});
523+
524+
test('throw when neither response nor execution error is provided', () => {
389525
// given
390526

391527
// when and then

sdk/src/__tests__/internal/network/NetworkClient.test.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
RequestHeaders,
77
RequestType
88
} from '../../../internal/network';
9+
import {QonversionError, QonversionErrorCode} from '../../../index';
910

1011
const networkClient = new NetworkClientImpl();
1112

@@ -31,7 +32,10 @@ describe('execute test', () => {
3132
const mockFetch = jest.fn(() =>
3233
Promise.resolve({
3334
status: testCode,
34-
json: () => Promise.resolve(testPayload),
35+
headers: {
36+
get: (header: string) => header === 'content-type' ? 'application/json' : null,
37+
},
38+
text: () => Promise.resolve(JSON.stringify(testPayload)),
3539
})
3640
);
3741

@@ -96,4 +100,112 @@ describe('execute test', () => {
96100
expect(result).toStrictEqual(expResult);
97101
expect(fetch).toBeCalledWith(testUrl, expRequest);
98102
});
103+
104+
test('execute with empty response body', async () => {
105+
// given
106+
const request: NetworkRequest = {
107+
headers: testHeaders,
108+
type: RequestType.GET,
109+
url: testUrl
110+
};
111+
mockFetch.mockImplementationOnce(() =>
112+
Promise.resolve({
113+
status: 204,
114+
headers: {
115+
get: () => null,
116+
},
117+
text: () => Promise.resolve(''),
118+
})
119+
);
120+
121+
// when
122+
const result = await networkClient.execute(request);
123+
124+
// then
125+
expect(result).toStrictEqual({
126+
code: 204,
127+
payload: undefined,
128+
});
129+
});
130+
131+
test('execute with plain text response body', async () => {
132+
// given
133+
const request: NetworkRequest = {
134+
headers: testHeaders,
135+
type: RequestType.GET,
136+
url: testUrl
137+
};
138+
mockFetch.mockImplementationOnce(() =>
139+
Promise.resolve({
140+
status: 503,
141+
headers: {
142+
get: (header: string) => header === 'content-type' ? 'text/plain' : null,
143+
},
144+
text: () => Promise.resolve('service unavailable'),
145+
})
146+
);
147+
148+
// when
149+
const result = await networkClient.execute(request);
150+
151+
// then
152+
expect(result).toStrictEqual({
153+
code: 503,
154+
payload: 'service unavailable',
155+
});
156+
});
157+
158+
test('execute with malformed json body', async () => {
159+
// given
160+
const request: NetworkRequest = {
161+
headers: testHeaders,
162+
type: RequestType.GET,
163+
url: testUrl
164+
};
165+
mockFetch.mockImplementationOnce(() =>
166+
Promise.resolve({
167+
status: 500,
168+
headers: {
169+
get: (header: string) => header === 'content-type' ? 'application/json' : null,
170+
},
171+
text: () => Promise.resolve('{'),
172+
})
173+
);
174+
175+
// when and then
176+
const execution = networkClient.execute(request);
177+
await expect(execution).rejects.toBeInstanceOf(QonversionError);
178+
await expect(execution).rejects.toMatchObject({
179+
code: QonversionErrorCode.BackendError,
180+
details: 'Failed to parse JSON response',
181+
responseCode: 500,
182+
});
183+
});
184+
185+
test('execute with whitespace-only json body', async () => {
186+
// given
187+
const request: NetworkRequest = {
188+
headers: testHeaders,
189+
type: RequestType.GET,
190+
url: testUrl
191+
};
192+
mockFetch.mockImplementationOnce(() =>
193+
Promise.resolve({
194+
status: 204,
195+
headers: {
196+
get: (header: string) => header === 'content-type' ? 'application/json' : null,
197+
},
198+
text: () => Promise.resolve('\n '),
199+
})
200+
);
201+
202+
// when
203+
const result = await networkClient.execute(request);
204+
205+
// then
206+
expect(result).toStrictEqual({
207+
code: 204,
208+
payload: undefined,
209+
});
210+
});
99211
});

0 commit comments

Comments
 (0)