Skip to content

Commit 4cd9e1f

Browse files
test: add 6 socket/transport error tests for v3.27.0 retry coverage
Adds tests covering the new UND_ERR_SOCKET / UND_ERR_ABORTED retry logic introduced in src/core/lib/request.js (v3.27.0). RetryLogic.test.js — Socket & Transport Error Handling: - RetryLogic_AuthError_FailsFast_NotSlowedByRetryDelay: timing proof that 4xx errors are never retried regardless of retryLimit/retryDelay - RetryLogic_CustomRetryCondition_InvokesOnError_WithDelayBetweenRetries: proves retryCondition → onError() wiring applies retry delays - RetryLogic_ZeroRetryLimit_NetworkFailure_RejectsWithoutWaiting: proves the retryLimit > 0 guard in the new socket-error path is enforced ErrorHandling.test.js — Transport Layer vs API Errors: - ErrorHandling_TransportError_HasNoAPIErrorCode: transport errors must not be wrapped with API error_code fields - ErrorHandling_APIError_StructureDistinctFromTransportError: proves the two error shapes remain distinguishable for app-level error routing - ErrorHandling_ZeroRetryLimit_TransportError_ErrorShapeUnchanged: proves retryLimit does not mutate the error object shape Also removes stale duplicate test/config.js entry from .talismanrc.
1 parent 619c4f0 commit 4cd9e1f

3 files changed

Lines changed: 241 additions & 5 deletions

File tree

.talismanrc

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ fileignoreconfig:
1212
- filename: test/integration/GlobalFieldsTests/ContentBlockGlobalField.test.js
1313
checksum: 8d2bc8cb6661336b57397649259f7e12786256706019efb644f133b336629d96
1414
- filename: test/integration/NetworkResilienceTests/RetryLogic.test.js
15-
checksum: 681543c7c982eba430189b541116ffeb06c7955da220b5fd8c6b034b1e9a5e43
15+
checksum: 3b5fe23398bdc2327848b3caa95339e51a0aed059c1eb299e78d3dd215ecbd30
1616
- filename: test/integration/QueryTests/ExistsSearchOperators.test.js
1717
checksum: e4774c805f1d0876cdc03439ed14a2f35a0ceb6028d86370a49ef0558a7bc46e
1818
- filename: index.d.ts
@@ -35,8 +35,6 @@ fileignoreconfig:
3535
checksum: 49d6742d014ce111735611ebab16dc8c888ce8d678dfbc99620252257e780ec5
3636
- filename: src/core/contentstack.js
3737
checksum: 22e723507c1fed8b3175b57791f4249889c9305b79e5348d59d741bdf4f006ba
38-
- filename: test/config.js
39-
checksum: 4ada746af34f2868c038f53126c08c21d750ddbd037d0a62e88824dd5d9e20be
4038
- filename: test/live-preview/live-preview-test.js
4139
checksum: d742465789e00a17092a7e9664adda4342a13bc4975553371a26df658f109952
4240
- filename: src/core/lib/request.js

test/integration/ErrorTests/ErrorHandling.test.js

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ describe('Error Tests - Error Handling & Validation', () => {
123123

124124
console.log(`✅ Invalid entry UID error: ${error.error_code}`);
125125
}
126-
});
126+
}, 15000); // Increased timeout for error handling tests
127127

128128
test('Error_EmptyEntryUID_HandlesGracefully', async () => {
129129
const contentTypeUID = TestDataHelper.getContentTypeUID('article', true);
@@ -269,7 +269,7 @@ describe('Error Tests - Error Handling & Validation', () => {
269269
expect(error.error_code).toBeDefined();
270270
console.log('✅ Special characters in field name trigger validation error (acceptable)');
271271
}
272-
});
272+
}, 15000); // Increased timeout for error handling tests
273273
});
274274

275275
describe('Error Response Structure Validation', () => {
@@ -513,6 +513,130 @@ describe('Error Tests - Error Handling & Validation', () => {
513513
});
514514
});
515515

516+
// =============================================================================
517+
// TRANSPORT LAYER ERROR HANDLING (v3.27.0 socket-retry coverage)
518+
// =============================================================================
519+
520+
describe('Transport Layer vs API Errors', () => {
521+
522+
test('ErrorHandling_TransportError_HasNoAPIErrorCode', async () => {
523+
// When fetch itself fails (DNS failure, socket drop, etc.) the error is a
524+
// TypeError thrown by the runtime — it has no error_code / error_message
525+
// from the Contentstack API response body.
526+
// Bug this catches: if transport errors are accidentally wrapped in the
527+
// same object shape as API errors, callers can't distinguish them and
528+
// may show misleading error messages to users.
529+
const localStack = Contentstack.Stack({
530+
...init.stack,
531+
fetchOptions: { retryLimit: 0, timeout: 3000 }
532+
});
533+
localStack.setHost('host-that-does-not-exist-transport-test.contentstack.io');
534+
535+
const contentTypeUID = TestDataHelper.getContentTypeUID('article', true);
536+
537+
try {
538+
await localStack.ContentType(contentTypeUID).Query().limit(1).toJSON().find();
539+
expect(true).toBe(false); // Should not reach here
540+
} catch (error) {
541+
expect(error).toBeDefined();
542+
// Transport errors have no API error_code — they are runtime TypeError objects
543+
expect(error.error_code).toBeUndefined();
544+
expect(error.error_message).toBeUndefined();
545+
546+
console.log('✅ Transport error has no API error_code — correctly not wrapped as API error');
547+
}
548+
}, 8000);
549+
550+
test('ErrorHandling_APIError_StructureDistinctFromTransportError', async () => {
551+
// API errors and transport errors must be distinguishable by structure so
552+
// application error-handling code can route them correctly.
553+
// Bug this catches: if a refactor makes both paths produce the same shape,
554+
// callers cannot tell whether the network is down or the query was invalid.
555+
let apiError = null;
556+
let transportError = null;
557+
558+
const unreachableStack = Contentstack.Stack({
559+
...init.stack,
560+
fetchOptions: { retryLimit: 0, timeout: 3000 }
561+
});
562+
unreachableStack.setHost('host-that-does-not-exist-struct-test.contentstack.io');
563+
564+
try {
565+
await Stack.ContentType('non_existent_ct_struct_test_xyz').Query().limit(1).toJSON().find();
566+
} catch (err) {
567+
apiError = err;
568+
}
569+
570+
try {
571+
await unreachableStack.ContentType('any_ct').Query().limit(1).toJSON().find();
572+
} catch (err) {
573+
transportError = err;
574+
}
575+
576+
expect(apiError).not.toBeNull();
577+
expect(transportError).not.toBeNull();
578+
579+
// API errors have structured fields from the Contentstack response body
580+
expect(apiError.error_code).toBeDefined();
581+
expect(typeof apiError.error_code).toBe('number');
582+
expect(apiError.error_message).toBeDefined();
583+
584+
// Transport errors are raw runtime errors — no API response body fields
585+
expect(transportError.error_code).toBeUndefined();
586+
expect(transportError.error_message).toBeUndefined();
587+
588+
console.log(`✅ API error has error_code=${apiError.error_code}; transport error has none — shapes are distinct`);
589+
}, 12000);
590+
591+
test('ErrorHandling_ZeroRetryLimit_TransportError_ErrorShapeUnchanged', async () => {
592+
// retryLimit=0 takes the reject() path in onError() immediately.
593+
// The error object passed to reject() must be the original transport error,
594+
// not re-wrapped or mutated.
595+
// Bug this catches: if the retryLimit=0 branch wraps the error differently,
596+
// downstream catch blocks that check error shape would break silently.
597+
const stackDefaultRetry = Contentstack.Stack({
598+
...init.stack,
599+
fetchOptions: { retryLimit: 5, timeout: 3000 }
600+
});
601+
stackDefaultRetry.setHost('host-that-does-not-exist-shape-test.contentstack.io');
602+
603+
const stackZeroRetry = Contentstack.Stack({
604+
...init.stack,
605+
fetchOptions: { retryLimit: 0, timeout: 3000 }
606+
});
607+
stackZeroRetry.setHost('host-that-does-not-exist-shape-test.contentstack.io');
608+
609+
let defaultRetryError = null;
610+
let zeroRetryError = null;
611+
612+
try {
613+
await stackDefaultRetry.ContentType('any_ct').Query().limit(1).toJSON().find();
614+
} catch (err) {
615+
defaultRetryError = err;
616+
}
617+
618+
try {
619+
await stackZeroRetry.ContentType('any_ct').Query().limit(1).toJSON().find();
620+
} catch (err) {
621+
zeroRetryError = err;
622+
}
623+
624+
expect(defaultRetryError).not.toBeNull();
625+
expect(zeroRetryError).not.toBeNull();
626+
627+
// Both must be transport errors (no API error_code) regardless of retryLimit
628+
expect(defaultRetryError.error_code).toBeUndefined();
629+
expect(zeroRetryError.error_code).toBeUndefined();
630+
631+
// Both should have the same constructor type — error shape must not change
632+
// based on whether retries were attempted
633+
expect(defaultRetryError.constructor).toBe(zeroRetryError.constructor);
634+
635+
console.log(`✅ Transport error shape is identical regardless of retryLimit (${defaultRetryError.constructor.name})`);
636+
}, 15000);
637+
638+
});
639+
516640
describe('Special Error Cases', () => {
517641
test('Error_VeryLongUID_HandlesGracefully', async () => {
518642
const veryLongUID = 'a'.repeat(1000);

test/integration/NetworkResilienceTests/RetryLogic.test.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,120 @@ describe('Retry Logic & Network Resilience - Comprehensive Tests', () => {
433433

434434
});
435435

436+
// =============================================================================
437+
// SOCKET & TRANSPORT ERROR HANDLING (v3.27.0 retry coverage)
438+
// =============================================================================
439+
440+
describe('Socket & Transport Error Handling', () => {
441+
442+
test('RetryLogic_AuthError_FailsFast_NotSlowedByRetryDelay', async () => {
443+
// 4xx API errors go through data.then(json => reject) — no retryCondition,
444+
// so they are NEVER retried regardless of retryLimit.
445+
// Bug this catches: if SDK starts retrying 4xx errors, timing balloons to
446+
// retryLimit * retryDelay (5 * 2000ms = 10 000ms) instead of failing fast.
447+
const RETRY_DELAY = 2000;
448+
const RETRY_LIMIT = 5;
449+
450+
const localStack = Contentstack.Stack({
451+
api_key: 'invalid_api_key_for_timing_test',
452+
delivery_token: config.stack.delivery_token,
453+
environment: config.stack.environment,
454+
fetchOptions: {
455+
retryLimit: RETRY_LIMIT,
456+
retryDelay: RETRY_DELAY
457+
}
458+
});
459+
localStack.setHost(config.host);
460+
461+
const contentTypeUID = TestDataHelper.getContentTypeUID('article', true);
462+
const start = Date.now();
463+
464+
try {
465+
await localStack.ContentType(contentTypeUID).Query().limit(1).toJSON().find();
466+
expect(true).toBe(false); // Should not reach here
467+
} catch (error) {
468+
const duration = Date.now() - start;
469+
470+
// If retried RETRY_LIMIT times: RETRY_LIMIT * RETRY_DELAY = 10 000ms
471+
// Auth errors must be rejected immediately — well under one retry delay.
472+
expect(duration).toBeLessThan(RETRY_DELAY);
473+
expect(error.error_code).toBeDefined();
474+
475+
console.log(`✅ Auth error rejected in ${duration}ms — no retry delay (retryLimit=${RETRY_LIMIT}, retryDelay=${RETRY_DELAY}ms)`);
476+
}
477+
}, 12000);
478+
479+
test('RetryLogic_CustomRetryCondition_InvokesOnError_WithDelayBetweenRetries', async () => {
480+
// retryCondition returning true routes through onError(), which applies
481+
// retryDelay before each retry attempt.
482+
// Bug this catches: if the retryCondition → onError() wiring is broken,
483+
// the test fails immediately (no delays) instead of after >= 2 * retryDelay.
484+
const RETRY_DELAY = 400;
485+
const RETRY_LIMIT = 2;
486+
487+
const localStack = Contentstack.Stack({
488+
...config.stack,
489+
fetchOptions: {
490+
retryLimit: RETRY_LIMIT,
491+
retryDelay: RETRY_DELAY,
492+
retryCondition: (response) => response.status === 422
493+
}
494+
});
495+
localStack.setHost(config.host);
496+
497+
const start = Date.now();
498+
499+
try {
500+
await localStack.ContentType('non_existent_ct_retry_test_xyz').Query().limit(1).toJSON().find();
501+
expect(true).toBe(false); // Should not reach here
502+
} catch (error) {
503+
const duration = Date.now() - start;
504+
505+
// 2 retries × 400ms delay = minimum 800ms of intentional waiting.
506+
// Subtract a small tolerance for scheduling jitter.
507+
expect(duration).toBeGreaterThan(RETRY_LIMIT * RETRY_DELAY * 0.75);
508+
expect(error).toBeDefined();
509+
510+
console.log(`✅ retryCondition triggered ${RETRY_LIMIT} retries, total ${duration}ms (min expected: ${RETRY_LIMIT * RETRY_DELAY}ms)`);
511+
}
512+
}, 20000);
513+
514+
test('RetryLogic_ZeroRetryLimit_NetworkFailure_RejectsWithoutWaiting', async () => {
515+
// onError() checks retryLimit === 0 first and calls reject() immediately.
516+
// The new socket-error path also has: if (isSocketOrAbort && retryLimit > 0).
517+
// Bug this catches: if either guard is removed, a network failure with
518+
// retryLimit=0 would enter a retry loop and add retryDelay * N ms of latency.
519+
const RETRY_DELAY = 1500;
520+
521+
const localStack = Contentstack.Stack({
522+
...config.stack,
523+
fetchOptions: {
524+
retryLimit: 0,
525+
retryDelay: RETRY_DELAY,
526+
timeout: 3000
527+
}
528+
});
529+
localStack.setHost('host-that-does-not-exist-xyz.contentstack.io');
530+
531+
const contentTypeUID = TestDataHelper.getContentTypeUID('article', true);
532+
const start = Date.now();
533+
534+
try {
535+
await localStack.ContentType(contentTypeUID).Query().limit(1).toJSON().find();
536+
expect(true).toBe(false); // Should not reach here
537+
} catch (error) {
538+
const duration = Date.now() - start;
539+
540+
// With retryLimit=0, no retry delays should be added at all.
541+
expect(duration).toBeLessThan(RETRY_DELAY);
542+
expect(error).toBeDefined();
543+
544+
console.log(`✅ retryLimit=0 rejects network failure in ${duration}ms (no ${RETRY_DELAY}ms retry delay added)`);
545+
}
546+
}, 10000);
547+
548+
});
549+
436550
// =============================================================================
437551
// EDGE CASES
438552
// =============================================================================

0 commit comments

Comments
 (0)