Skip to content

Commit cbd293b

Browse files
committed
Merge origin/master (PR #504 hotfix DX-4429) into feat/dam-2.0-test-cases
2 parents 5828b96 + 865d3c7 commit cbd293b

File tree

11 files changed

+2576
-2011
lines changed

11 files changed

+2576
-2011
lines changed

.talismanrc

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
fileignoreconfig:
2+
- filename: lib/contentstackClient.js
3+
checksum: f564f6eee5c17dc73abdeab4be226a3b37942893e149d907d2a4ef415c485c5e
24
- filename: test/unit/globalField-test.js
35
checksum: 25185e3400a12e10a043dc47502d8f30b7e1c4f2b6b4d3b8b55cdc19850c48bf
46
- filename: lib/stack/index.js
@@ -7,7 +9,9 @@ fileignoreconfig:
79
ignore_detectors:
810
- filecontent
911
- filename: package-lock.json
10-
checksum: 17b5bbabcc58beaa180a7fa931fc3fb407ee0e3447d47da224f60118c0a4c294
12+
checksum: 92b88ce00603ede68344bac6bd6bf76bdb76f1e5f5ba8d1d0c79da2b72c5ecc0
13+
- filename: test/unit/ContentstackClient-test.js
14+
checksum: 5d8519b5b93c715e911a62b4033614cc4fb3596eabf31c7216ecb4cc08604a73
1115
- filename: .husky/pre-commit
1216
checksum: 52a664f536cf5d1be0bea19cb6031ca6e8107b45b6314fe7d47b7fad7d800632
1317
- filename: lib/stack/asset/index.js
@@ -18,10 +22,6 @@ fileignoreconfig:
1822
checksum: b506f33bffdd20dfc701f964370707f5d7b28a2c05c70665f0edb7b3c53c165b
1923
- filename: examples/robust-error-handling.js
2024
checksum: e8a32ffbbbdba2a15f3d327273f0a5b4eb33cf84cd346562596ab697125bbbc6
21-
- filename: lib/contentstackClient.js
22-
checksum: b76ca091caa3a1b2658cd422a2d8ef3ac9996aea0aff3f982d56bb309a3d9fde
23-
- filename: test/unit/ContentstackClient-test.js
24-
checksum: 974a4f335aef025b657d139bb290233a69bed1976b947c3c674e97baffe4ce2f
2525
- filename: test/unit/ContentstackHTTPClient-test.js
2626
checksum: 4043efd843e24da9afd0272c55ef4b0432e3374b2ca12b913f1a6654df3f62be
2727
- filename: test/unit/contentstack-test.js
@@ -105,4 +105,6 @@ fileignoreconfig:
105105
checksum: 1ba486167f2485853d9574322c233d28fc566e02db44bb9831b70fb9afaf7631
106106
- filename: test/sanity-check/mock/index.js
107107
checksum: 6c0d8f6e7c85cd2fa5f0a20e8a49e94df0dde1b2c1d7e9c39e8c9c6c8b8d5e2f1
108+
- filename: test/unit/concurrency-Queue-test.js
109+
checksum: fd5c327f4fa1b334fdb1a2d903ac0213752e7829f31f19667215aa186c3efbbf
108110
version: "1.0"

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## [v1.27.5](https://github.com/contentstack/contentstack-management-javascript/tree/v1.27.5) (2026-02-11)
4+
- Fix
5+
- Concurrency queue: when response errors have no `config` (e.g. after network retries exhaust in some environments, or when plugins return a new error object), the SDK now rejects with a catchable Error instead of throwing an unhandled TypeError and crashing the process
6+
- Hardened `responseHandler` to safely handle errors without `config` (e.g. plugin-replaced errors) by guarding `config.onComplete` and still running queue `shift()` so rejections remain catchable
7+
- Added optional chaining for `error.config` reads in the retry path and unit tests for missing-config scenarios
8+
9+
## [v1.27.4](https://github.com/contentstack/contentstack-management-javascript/tree/v1.27.4) (2026-02-02)
10+
- Fix
11+
- Removed content-type header from the release delete method
12+
- Plugin `onResponse` hook is now called for error responses; errors handled by the concurrency queue (e.g. 5xx, retries exhausted) were previously not running plugin hooks
13+
14+
## [v1.27.3](https://github.com/contentstack/contentstack-management-javascript/tree/v1.27.3) (2026-01-21)
15+
- Fix
16+
- Skip token refresh and preserve error_code 294 when 2FA is required (error_code 294 with 401 status) to prevent error code conversion from 294 to 401
17+
318
## [v1.27.2](https://github.com/contentstack/contentstack-management-javascript/tree/v1.27.2) (2026-01-12)
419
- Enhancement
520
- Improved error messages

lib/core/concurrency-queue.js

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const defaultConfig = {
5252
* @returns {Object} ConcurrencyQueue instance with request/response interceptors attached to Axios.
5353
* @throws {Error} If axios instance is not provided or configuration is invalid.
5454
*/
55-
export function ConcurrencyQueue ({ axios, config }) {
55+
export function ConcurrencyQueue ({ axios, config, plugins = [] }) {
5656
if (!axios) {
5757
throw Error(ERROR_MESSAGES.AXIOS_INSTANCE_MISSING)
5858
}
@@ -172,7 +172,7 @@ export function ConcurrencyQueue ({ axios, config }) {
172172
logFinalFailure(errorInfo, this.config.maxNetworkRetries)
173173
// Final error message
174174
const finalError = new Error(`Network request failed after ${this.config.maxNetworkRetries} retries: ${errorInfo.reason}`)
175-
finalError.code = error.code
175+
finalError.code = error && error.code
176176
finalError.originalError = error
177177
finalError.retryAttempts = attempt - 1
178178
return Promise.reject(finalError)
@@ -181,6 +181,16 @@ export function ConcurrencyQueue ({ axios, config }) {
181181
const delay = calculateNetworkRetryDelay(attempt)
182182
logRetryAttempt(errorInfo, attempt, delay)
183183

184+
// Guard: retry failures (e.g. from nested retries) may not have config in some
185+
// environments. Reject with a catchable error instead of throwing TypeError.
186+
if (!error || !error.config) {
187+
const finalError = new Error(`Network request failed after retries: ${errorInfo.reason}`)
188+
finalError.code = error && error.code
189+
finalError.originalError = error
190+
finalError.retryAttempts = attempt - 1
191+
return Promise.reject(finalError)
192+
}
193+
184194
// Initialize retry count if not present
185195
if (!error.config.networkRetryCount) {
186196
error.config.networkRetryCount = 0
@@ -200,9 +210,7 @@ export function ConcurrencyQueue ({ axios, config }) {
200210
safeAxiosRequest(requestConfig)
201211
.then((response) => {
202212
// On successful retry, call the original onComplete to properly clean up
203-
if (error.config.onComplete) {
204-
error.config.onComplete()
205-
}
213+
error?.config?.onComplete?.()
206214
shift() // Process next queued request
207215
resolve(response)
208216
})
@@ -214,17 +222,13 @@ export function ConcurrencyQueue ({ axios, config }) {
214222
.then(resolve)
215223
.catch((finalError) => {
216224
// On final failure, clean up the running queue
217-
if (error.config.onComplete) {
218-
error.config.onComplete()
219-
}
225+
error?.config?.onComplete?.()
220226
shift() // Process next queued request
221227
reject(finalError)
222228
})
223229
} else {
224230
// On non-retryable error, clean up the running queue
225-
if (error.config.onComplete) {
226-
error.config.onComplete()
227-
}
231+
error?.config?.onComplete?.()
228232
shift() // Process next queued request
229233
reject(retryError)
230234
}
@@ -429,50 +433,101 @@ export function ConcurrencyQueue ({ axios, config }) {
429433
}
430434
})
431435
}
432-
// Response interceptor used for
436+
// Response interceptor used for success and for error path (Promise.reject(responseHandler(err))).
437+
// When used with an error, err may lack config (e.g. plugin returns new error). Guard so we don't throw.
433438
const responseHandler = (response) => {
434-
response.config.onComplete()
439+
if (response?.config?.onComplete) {
440+
response.config.onComplete()
441+
}
435442
shift()
436443
return response
437444
}
438445

446+
// Run plugin onResponse hooks for errors so plugins see every error (including
447+
// those that are rejected without going through the plugin response interceptor).
448+
const runPluginOnResponseForError = (error) => {
449+
if (!plugins || !plugins.length) return error
450+
let currentError = error
451+
for (const plugin of plugins) {
452+
try {
453+
if (typeof plugin.onResponse === 'function') {
454+
const result = plugin.onResponse(currentError)
455+
if (result !== undefined) currentError = result
456+
}
457+
} catch (e) {
458+
if (this.config.logHandler) {
459+
this.config.logHandler('error', {
460+
name: 'PluginError',
461+
message: `Error in plugin onResponse (error handler): ${e.message}`,
462+
error: e
463+
})
464+
}
465+
}
466+
}
467+
return currentError
468+
}
469+
439470
const responseErrorHandler = error => {
440-
let networkError = error.config.retryCount
471+
// Guard: Axios errors normally have config; missing config can occur when a retry
472+
// fails in certain environments or when non-Axios errors propagate (e.g. timeouts).
473+
// Reject with a catchable error instead of throwing TypeError and crashing the process.
474+
if (!error || !error.config) {
475+
const fallbackError = new Error(
476+
error && typeof error.message === 'string'
477+
? error.message
478+
: 'Network request failed: error object missing request config'
479+
)
480+
fallbackError.code = error?.code
481+
fallbackError.originalError = error
482+
return Promise.reject(runPluginOnResponseForError(fallbackError))
483+
}
484+
485+
let networkError = error?.config?.retryCount ?? 0
441486
let retryErrorType = null
442487

443488
// First, check for transient network errors
444489
const networkErrorInfo = isTransientNetworkError(error)
445490
if (networkErrorInfo && this.config.retryOnNetworkFailure) {
446-
const networkRetryCount = error.config.networkRetryCount || 0
491+
const networkRetryCount = error?.config?.networkRetryCount || 0
447492
return retryNetworkError(error, networkErrorInfo, networkRetryCount + 1)
448493
}
449494

450495
// Original retry logic for non-network errors
451496
if (!this.config.retryOnError || networkError > this.config.retryLimit) {
452-
return Promise.reject(responseHandler(error))
497+
const err = runPluginOnResponseForError(error)
498+
return Promise.reject(responseHandler(err))
453499
}
454500

455501
// Check rate limit remaining header before retrying
456502
const wait = this.config.retryDelay
457503
var response = error.response
458504
if (!response) {
459505
if (error.code === 'ECONNABORTED') {
460-
const timeoutMs = error.config.timeout || this.config.timeout || 'unknown'
506+
const timeoutMs = error?.config?.timeout || this.config.timeout || 'unknown'
461507
error.response = {
462508
...error.response,
463509
status: 408,
464510
statusText: `timeout of ${timeoutMs}ms exceeded`
465511
}
466512
response = error.response
467513
} else {
468-
return Promise.reject(responseHandler(error))
514+
const err = runPluginOnResponseForError(error)
515+
return Promise.reject(responseHandler(err))
469516
}
470517
} else if ((response.status === 401 && this.config.refreshToken)) {
518+
// If error_code is 294 (2FA required), don't retry/refresh - pass through the error as-is
519+
const apiErrorCode = response.data?.error_code
520+
if (apiErrorCode === 294) {
521+
const err = runPluginOnResponseForError(error)
522+
return Promise.reject(responseHandler(err))
523+
}
524+
471525
retryErrorType = `Error with status: ${response.status}`
472526
networkError++
473527

474528
if (networkError > this.config.retryLimit) {
475-
return Promise.reject(responseHandler(error))
529+
const err = runPluginOnResponseForError(error)
530+
return Promise.reject(responseHandler(err))
476531
}
477532
this.running.shift()
478533
// Cool down the running requests
@@ -486,19 +541,22 @@ export function ConcurrencyQueue ({ axios, config }) {
486541
networkError++
487542
return this.retry(error, retryErrorType, networkError, wait)
488543
}
489-
return Promise.reject(responseHandler(error))
544+
const err = runPluginOnResponseForError(error)
545+
return Promise.reject(responseHandler(err))
490546
}
491547

492548
this.retry = (error, retryErrorType, retryCount, waittime) => {
493549
let delaytime = waittime
494550
if (retryCount > this.config.retryLimit) {
495-
return Promise.reject(responseHandler(error))
551+
const err = runPluginOnResponseForError(error)
552+
return Promise.reject(responseHandler(err))
496553
}
497554
if (this.config.retryDelayOptions) {
498555
if (this.config.retryDelayOptions.customBackoff) {
499556
delaytime = this.config.retryDelayOptions.customBackoff(retryCount, error)
500557
if (delaytime && delaytime <= 0) {
501-
return Promise.reject(responseHandler(error))
558+
const err = runPluginOnResponseForError(error)
559+
return Promise.reject(responseHandler(err))
502560
}
503561
} else if (this.config.retryDelayOptions.base) {
504562
delaytime = this.config.retryDelayOptions.base * retryCount

lib/core/contentstackHTTPClient.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,11 @@ export default function contentstackHttpClient (options) {
109109
}
110110
const instance = axios.create(axiosOptions)
111111
instance.httpClientParams = options
112-
instance.concurrencyQueue = new ConcurrencyQueue({ axios: instance, config })
113112

114-
// Normalize and store plugins
115-
const plugins = normalizePlugins(config.plugins)
113+
// Normalize and store plugins before ConcurrencyQueue so plugin interceptors
114+
// run after the queue's (plugin sees responses/errors before they reach the queue).
115+
// Use options.plugins so hooks run against the same plugin references (spies work in tests).
116+
const plugins = normalizePlugins(options.plugins || config.plugins)
116117

117118
// Request interceptor for versioning strategy (must run first)
118119
instance.interceptors.request.use((request) => {
@@ -235,5 +236,7 @@ export default function contentstackHttpClient (options) {
235236
)
236237
}
237238

239+
instance.concurrencyQueue = new ConcurrencyQueue({ axios: instance, config, plugins })
240+
238241
return instance
239242
}

lib/stack/release/index.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
create,
44
fetch,
55
update,
6-
deleteEntity,
76
query
87
} from '../../entity'
98
import error from '../../core/contentstackError'
@@ -94,7 +93,31 @@ export function Release (http, data = {}) {
9493
* client.stack({ api_key: 'api_key'}).release('release_uid').delete()
9594
* .then((response) => console.log(response.notice))
9695
*/
97-
this.delete = deleteEntity(http)
96+
this.delete = (function (http) {
97+
return async function (param = {}) {
98+
try {
99+
const requestConfig = {
100+
headers: { ...cloneDeep(this.stackHeaders) },
101+
params: { ...cloneDeep(param) }
102+
}
103+
// Omit Content-Type for DELETE; no body is sent and fastify v5 rejects it when present
104+
requestConfig.headers['Content-Type'] = null
105+
const response = await http.delete(this.urlPath, requestConfig)
106+
if (response.data) {
107+
return response.data
108+
}
109+
if (response.status >= 200 && response.status < 300) {
110+
return {
111+
status: response.status,
112+
statusText: response.statusText
113+
}
114+
}
115+
throw error(response)
116+
} catch (err) {
117+
throw error(err)
118+
}
119+
}
120+
})(http)
98121

99122
/**
100123
* @description A ReleaseItem is a set of entries and assets that needs to be deployed (published or unpublished) all at once to a particular environment.

0 commit comments

Comments
 (0)