@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 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.4...main
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.4",
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.1"
25
+ "soap": "^1.9.3"
26
26
  },
27
27
  "devDependencies": {
28
- "vitest": "^4.1.5",
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
- const client = await SOAP.createClientAsync(url, {
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
- retries: 3,
250
- minTimeout: 1000, // Start with 1 second delay between retries
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
+ })