@open-xchange/soap-client 0.1.4 → 0.1.6
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/CHANGELOG.md +19 -1
- package/package.json +3 -3
- package/soap.js +51 -10
- package/test/soap.test.js +43 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.6] - 2026-05-26
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Aborting a non-retryable SOAP fault no longer corrupts the error message. p-retry v8 passes `onFailedAttempt` a context object (`{ error, attemptNumber, retriesLeft, ... }`) rather than the error itself; the abort path was passing that context object to `AbortError`, which stored it verbatim as `.message`. p-retry rejects with that `AbortError` as-is (it only unwraps `AbortError`s thrown by the retried function, not by the callback), so consumers received an error whose `.message` was an object and crashed with `error.message.includes is not a function` — masking the real fault. We now pass the inner `context.error`, keeping `.message` a string and letting `wrapSoapFault` recover the fault downstream. Both retry sites (WSDL fetch and per-method calls) share one `handleFailedAttempt` helper.
|
|
12
|
+
|
|
13
|
+
## [0.1.5] - 2026-05-26
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Retry the WSDL fetch on transient network errors (ECONNRESET / ETIMEDOUT / etc.). The per-method `pRetry` wrapper was already handling SOAP method calls, but `SOAP.createClientAsync()` itself (which fetches the WSDL via axios under the hood) was unretried, so a single TLS hiccup during bootstrap would kill the whole test run.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Retry policy tuned to ride out a recovery window of up to ~3 minutes per call: 10 retries (11 attempts total) with exponential backoff from 1s up to 30s, capped, with jitter. Previous 3-retry / 10s-cap budget was useful for one-packet blips but ran out before a recovering service could come back. The same policy now applies to both the WSDL fetch and per-method SOAP calls (factored into a shared constant). Permanent SOAP faults (already-exists, auth failure, etc.) still abort immediately via `shouldAbortRetry`.
|
|
22
|
+
|
|
7
23
|
## [0.1.4] - 2026-05-21
|
|
8
24
|
|
|
9
25
|
### Fixed
|
|
@@ -103,7 +119,9 @@ All notable changes to this project will be documented in this file.
|
|
|
103
119
|
|
|
104
120
|
- Initial release: extract SOAP client into its own library
|
|
105
121
|
|
|
106
|
-
[unreleased]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.
|
|
122
|
+
[unreleased]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.6...main
|
|
123
|
+
[0.1.6]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.5...soap-client-0.1.6
|
|
124
|
+
[0.1.5]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.4...soap-client-0.1.5
|
|
107
125
|
[0.1.4]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.3...soap-client-0.1.4
|
|
108
126
|
[0.1.3]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.2...soap-client-0.1.3
|
|
109
127
|
[0.1.2]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.1...soap-client-0.1.2
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-xchange/soap-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "SOAP client for OX App Suite",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"commander": "^14.0.3",
|
|
24
24
|
"p-retry": "^8.0.0",
|
|
25
|
-
"soap": "^1.9.
|
|
25
|
+
"soap": "^1.9.3"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"vitest": "^4.1.
|
|
28
|
+
"vitest": "^4.1.7",
|
|
29
29
|
"@open-xchange/lint": "0.3.0"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
package/soap.js
CHANGED
|
@@ -25,6 +25,21 @@ import pRetry, { AbortError as RetryAbortError } from 'p-retry'
|
|
|
25
25
|
// Set AbortError correctly
|
|
26
26
|
const AbortError = RetryAbortError
|
|
27
27
|
|
|
28
|
+
// Shared retry policy for both the WSDL fetch and per-method SOAP calls.
|
|
29
|
+
// Designed to ride out a service recovery window of up to ~3 minutes:
|
|
30
|
+
// fast retries for the first ~30s catch transient blips cheaply, then the
|
|
31
|
+
// schedule settles into 30s polling so a recovering service is picked up
|
|
32
|
+
// within one poll. Note: each pRetry cycle is independent, so back-to-back
|
|
33
|
+
// failures (WSDL + method) can spend up to ~6 minutes total — keep the CI
|
|
34
|
+
// job timeout above that or recovery won't be observable.
|
|
35
|
+
const RETRY_OPTIONS = {
|
|
36
|
+
retries: 10, // 11 attempts total
|
|
37
|
+
factor: 2, // exponential growth, doubling each attempt
|
|
38
|
+
minTimeout: 1000, // first wait = 1s
|
|
39
|
+
maxTimeout: 30000, // cap at 30s once growth exceeds it
|
|
40
|
+
randomize: true // jitter to avoid thundering herd across parallel shards
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
for (const envFile of ['.env', '.env.defaults']) {
|
|
29
44
|
try { process.loadEnvFile(envFile) } catch {}
|
|
30
45
|
}
|
|
@@ -202,6 +217,31 @@ function shouldAbortRetry (error) {
|
|
|
202
217
|
}
|
|
203
218
|
}
|
|
204
219
|
|
|
220
|
+
/**
|
|
221
|
+
* p-retry v8 `onFailedAttempt` handler, shared by both retry sites below.
|
|
222
|
+
*
|
|
223
|
+
* IMPORTANT: p-retry v8 invokes `onFailedAttempt` with a *context object*
|
|
224
|
+
* `{ error, attemptNumber, retriesLeft, retriesConsumed, retryDelay }` — NOT
|
|
225
|
+
* the error itself (that was the pre-v7 signature). So when we abort, we must
|
|
226
|
+
* pass `context.error` (the real Error) to `AbortError`, not `context`.
|
|
227
|
+
*
|
|
228
|
+
* Why it matters: `new AbortError(nonError)` sets `AbortError.message` to the
|
|
229
|
+
* value verbatim, and p-retry does not unwrap an AbortError thrown *by this
|
|
230
|
+
* callback* — it rejects with it as-is. Passing `context` would therefore
|
|
231
|
+
* reject with an Error whose `.message` is an object, which later blows up
|
|
232
|
+
* consumers doing `error.message.includes(...)` (appsuite-codeceptjs
|
|
233
|
+
* contexts.js) with "error.message.includes is not a function", masking the
|
|
234
|
+
* real SOAP fault. Passing `context.error` keeps `.message` a string and lets
|
|
235
|
+
* `wrapSoapFault` recover the fault downstream.
|
|
236
|
+
*
|
|
237
|
+
* @param {object} context p-retry attempt context.
|
|
238
|
+
* @param {string} label Human-readable label for the retry log line.
|
|
239
|
+
*/
|
|
240
|
+
function handleFailedAttempt (context, label) {
|
|
241
|
+
if (shouldAbortRetry(context)) throw new AbortError(context.error)
|
|
242
|
+
console.log(`Retrying ${label} in ${context.retriesLeft} attempts (${describeError(context)})`)
|
|
243
|
+
}
|
|
244
|
+
|
|
205
245
|
/**
|
|
206
246
|
* This function creates a SOAP client for the specified service type.
|
|
207
247
|
* @param {string} type The name of the service type.
|
|
@@ -213,13 +253,20 @@ async function createClientAsync (type) {
|
|
|
213
253
|
performance.mark(startMark)
|
|
214
254
|
const endpoint = `${provisioningUrl}/webservices/${type}`
|
|
215
255
|
const url = `${endpoint}/?wsdl`
|
|
216
|
-
|
|
256
|
+
// The WSDL fetch itself can hit transient network errors (ECONNRESET,
|
|
257
|
+
// ETIMEDOUT, ...). Without retry here, a single TLS hiccup during
|
|
258
|
+
// bootstrap kills the whole job before any per-method pRetry below has
|
|
259
|
+
// a chance to run.
|
|
260
|
+
const client = await pRetry(() => SOAP.createClientAsync(url, {
|
|
217
261
|
endpoint,
|
|
218
262
|
suppressStack: true,
|
|
219
263
|
wsdl_options: {
|
|
220
264
|
forever: true
|
|
221
265
|
},
|
|
222
266
|
gzip: true
|
|
267
|
+
}), {
|
|
268
|
+
...RETRY_OPTIONS,
|
|
269
|
+
onFailedAttempt: context => handleFailedAttempt(context, `WSDL fetch (${type})`)
|
|
223
270
|
})
|
|
224
271
|
|
|
225
272
|
// https://stackoverflow.com/questions/30740415/namespace-for-array-field-in-node-soap-client-node-js
|
|
@@ -246,14 +293,8 @@ async function createClientAsync (type) {
|
|
|
246
293
|
|
|
247
294
|
try {
|
|
248
295
|
const result = await pRetry(() => origMethod.apply(this, [soapOptions, { timeout: 30000, ...clientOptions }, ...args]), {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
maxTimeout: 10000, // Cap retry delay at 10 seconds
|
|
252
|
-
randomize: true, // Add jitter to spread out concurrent retries
|
|
253
|
-
onFailedAttempt: async error => {
|
|
254
|
-
if (shouldAbortRetry(error)) throw new AbortError(error)
|
|
255
|
-
console.log(`Retrying ${String(prop)} in ${error.retriesLeft} attempts (${describeError(error)})`)
|
|
256
|
-
}
|
|
296
|
+
...RETRY_OPTIONS,
|
|
297
|
+
onFailedAttempt: context => handleFailedAttempt(context, String(prop))
|
|
257
298
|
})
|
|
258
299
|
|
|
259
300
|
performance.mark(endMark)
|
|
@@ -272,4 +313,4 @@ async function createClientAsync (type) {
|
|
|
272
313
|
})
|
|
273
314
|
}
|
|
274
315
|
|
|
275
|
-
export { createClientAsync, logSoapError, describeError, shouldAbortRetry, wrapSoapFault, unwrapError }
|
|
316
|
+
export { createClientAsync, logSoapError, describeError, shouldAbortRetry, wrapSoapFault, unwrapError, handleFailedAttempt }
|
package/test/soap.test.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// We don't exercise that path here; set a stub so the import doesn't crash.
|
|
8
8
|
process.env.PROVISIONING_URL = 'http://soap.test/'
|
|
9
9
|
|
|
10
|
-
const { describeError, shouldAbortRetry, wrapSoapFault } = await import('../soap.js')
|
|
10
|
+
const { describeError, shouldAbortRetry, wrapSoapFault, handleFailedAttempt } = await import('../soap.js')
|
|
11
11
|
|
|
12
12
|
// Build a SOAP fault object matching the real shape (from soap@1.9.x).
|
|
13
13
|
function soapFault (faultstring, detail = {}) {
|
|
@@ -224,3 +224,45 @@ describe('real-world: transient "Could not acquire claim" fault wrapped in { err
|
|
|
224
224
|
expect(out.body).toMatch(/soap:Envelope/)
|
|
225
225
|
})
|
|
226
226
|
})
|
|
227
|
+
|
|
228
|
+
describe('handleFailedAttempt (p-retry v8 onFailedAttempt)', () => {
|
|
229
|
+
// p-retry v8 hands the callback a context object, not the error itself.
|
|
230
|
+
function attemptContext (faultstring, { retriesLeft = 3 } = {}) {
|
|
231
|
+
const error = new Error(`soap:Server: ${faultstring}`)
|
|
232
|
+
Object.assign(error, soapFault(faultstring))
|
|
233
|
+
return { error, attemptNumber: 1, retriesLeft, retriesConsumed: 0, retryDelay: 0 }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
it('aborts a blocked fault with a STRING message — the original incident', () => {
|
|
237
|
+
// Regression: this is the "Mandatory fields in context not set: [id]"
|
|
238
|
+
// fault that aborts retry. Before the fix, `new AbortError(context)`
|
|
239
|
+
// stored the context object as `.message`, so downstream
|
|
240
|
+
// `error.message.includes(...)` threw "includes is not a function".
|
|
241
|
+
let thrown
|
|
242
|
+
try {
|
|
243
|
+
handleFailedAttempt(attemptContext('Mandatory fields in context not set: [id]'), 'create')
|
|
244
|
+
} catch (e) {
|
|
245
|
+
thrown = e
|
|
246
|
+
}
|
|
247
|
+
expect(thrown).toBeInstanceOf(Error)
|
|
248
|
+
expect(typeof thrown.message).toBe('string')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('aborts with the inner Error so wrapSoapFault can still recover the fault', () => {
|
|
252
|
+
let thrown
|
|
253
|
+
try {
|
|
254
|
+
handleFailedAttempt(attemptContext('Mandatory fields in context not set: [id]'), 'create')
|
|
255
|
+
} catch (e) {
|
|
256
|
+
thrown = e
|
|
257
|
+
}
|
|
258
|
+
const recovered = wrapSoapFault(thrown)
|
|
259
|
+
expect(recovered.message).toContain('Mandatory fields in context not set: [id]')
|
|
260
|
+
expect(recovered.faultstring).toContain('Mandatory fields in context not set: [id]')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('does not throw for a transient (non-blocked) fault — lets p-retry continue', () => {
|
|
264
|
+
expect(() =>
|
|
265
|
+
handleFailedAttempt(attemptContext('Could not acquire claim for context 2572225'), 'create')
|
|
266
|
+
).not.toThrow()
|
|
267
|
+
})
|
|
268
|
+
})
|