@open-xchange/soap-client 0.1.2 → 0.1.4
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 +32 -2
- package/README.md +8 -8
- package/package.json +4 -2
- package/services/sharedAccount.js +1 -1
- package/soap.js +88 -13
- package/test/soap.test.js +226 -0
- package/vitest.config.js +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.4] - 2026-05-21
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Retry log lines no longer print `(undefined)` when SOAP errors come wrapped in `{ error: ... }` envelopes
|
|
12
|
+
- Preserve `faultstring`, `faultcode`, `detail`, `root`, `response`, `body` on thrown errors so consumers can pattern-match on the SOAP fault structure
|
|
13
|
+
- Unwrap `original_error` (snake_case) from `PropagatedError` chains in addition to `originalError` / `error`
|
|
14
|
+
|
|
15
|
+
## [0.1.3] - 2026-04-28
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Use default admin credentials for `convertUserToSharedAccount` SOAP call
|
|
20
|
+
|
|
7
21
|
## [0.1.2] - 2026-04-21
|
|
8
22
|
|
|
9
23
|
### Added
|
|
@@ -35,7 +49,7 @@ All notable changes to this project will be documented in this file.
|
|
|
35
49
|
|
|
36
50
|
- Exponential backoff with jitter to SOAP client retries
|
|
37
51
|
|
|
38
|
-
##
|
|
52
|
+
## 0.0.9 - 2025-12-12
|
|
39
53
|
|
|
40
54
|
### Changed
|
|
41
55
|
|
|
@@ -83,8 +97,24 @@ All notable changes to this project will be documented in this file.
|
|
|
83
97
|
|
|
84
98
|
- Update dependencies
|
|
85
99
|
|
|
86
|
-
##
|
|
100
|
+
## 0.0.1 - 2025-02-28
|
|
87
101
|
|
|
88
102
|
### Added
|
|
89
103
|
|
|
90
104
|
- Initial release: extract SOAP client into its own library
|
|
105
|
+
|
|
106
|
+
[unreleased]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.4...main
|
|
107
|
+
[0.1.4]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.3...soap-client-0.1.4
|
|
108
|
+
[0.1.3]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.2...soap-client-0.1.3
|
|
109
|
+
[0.1.2]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.1...soap-client-0.1.2
|
|
110
|
+
[0.1.0]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.12...soap-client-0.1.0
|
|
111
|
+
[0.0.12]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.11...soap-client-0.0.12
|
|
112
|
+
[0.0.11]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.10...soap-client-0.0.11
|
|
113
|
+
[0.0.10]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.8...soap-client-0.0.10
|
|
114
|
+
[0.0.8]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.7...soap-client-0.0.8
|
|
115
|
+
[0.0.7]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.6...soap-client-0.0.7
|
|
116
|
+
[0.0.6]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.5...soap-client-0.0.6
|
|
117
|
+
[0.0.5]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.4...soap-client-0.0.5
|
|
118
|
+
[0.0.4]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.3...soap-client-0.0.4
|
|
119
|
+
[0.0.3]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.2...soap-client-0.0.3
|
|
120
|
+
[0.0.2]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/tags/soap-client-0.0.2
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ This project provides an API facade for the OX App Suite Middleware SOAP API, ma
|
|
|
15
15
|
You can use the built-in provisioning script to automate resource creation. Simply run:
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
|
|
18
|
+
pnpm dlx @open-xchange/soap-client
|
|
19
19
|
```
|
|
20
20
|
If you already have installed the package in your project, you can run the script directly:
|
|
21
21
|
|
|
@@ -26,7 +26,7 @@ pnpm provision.js
|
|
|
26
26
|
This will execute the provisioning tool, which reads configuration from a JSON file (default: `./provisioning.json`). You can specify a different file with the `-f` option:
|
|
27
27
|
|
|
28
28
|
```sh
|
|
29
|
-
|
|
29
|
+
pnpm dlx @open-xchange/soap-client -f example-provisioning.json
|
|
30
30
|
```
|
|
31
31
|
The JSON file should contain the resources to provision. For example:
|
|
32
32
|
|
|
@@ -81,17 +81,17 @@ The JSON file should contain the resources to provision. For example:
|
|
|
81
81
|
The CLI supports creating contexts, users, and secondary accounts:
|
|
82
82
|
|
|
83
83
|
```sh
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
pnpm dlx @open-xchange/soap-client create context --name my-context
|
|
85
|
+
pnpm dlx @open-xchange/soap-client create user --context-id 123 --email user@example.com --name myuser
|
|
86
|
+
pnpm dlx @open-xchange/soap-client create account --context-id 123 --name info --email info@example.com --users 1,2,3
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
### Example: Deleting Resources
|
|
90
90
|
|
|
91
91
|
```sh
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
pnpm dlx @open-xchange/soap-client delete context --id 123
|
|
93
|
+
pnpm dlx @open-xchange/soap-client delete user --context-id 123 --id 456
|
|
94
|
+
pnpm dlx @open-xchange/soap-client delete account --context-id 123 --email info@example.com --users 1,2,3
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
## API Usage
|
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.4",
|
|
4
4
|
"description": "SOAP client for OX App Suite",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -22,13 +22,15 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"commander": "^14.0.3",
|
|
24
24
|
"p-retry": "^8.0.0",
|
|
25
|
-
"soap": "^1.
|
|
25
|
+
"soap": "^1.9.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
+
"vitest": "^4.1.5",
|
|
28
29
|
"@open-xchange/lint": "0.3.0"
|
|
29
30
|
},
|
|
30
31
|
"scripts": {
|
|
31
32
|
"lint": "eslint .",
|
|
33
|
+
"test": "vitest --run",
|
|
32
34
|
"provision": "node ./bin/provision.js"
|
|
33
35
|
}
|
|
34
36
|
}
|
package/soap.js
CHANGED
|
@@ -32,14 +32,59 @@ for (const envFile of ['.env', '.env.defaults']) {
|
|
|
32
32
|
// This flag enables debug output including timing information.
|
|
33
33
|
const debug = process.env.DEBUG_SOAP === 'true'
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Walk one layer of common error-wrapper shapes:
|
|
37
|
+
* - `{ originalError }` — soap-client retry/catch chain
|
|
38
|
+
* - `{ error }` — soap@1.9.x http handler envelope
|
|
39
|
+
* - `{ original_error }` — appsuite-codeceptjs `PropagatedError` (snake_case)
|
|
40
|
+
*
|
|
41
|
+
* Returns the inner value, or the input unchanged if it doesn't match any shape.
|
|
42
|
+
*
|
|
43
|
+
* @param {any} e
|
|
44
|
+
* @returns {any}
|
|
45
|
+
*/
|
|
46
|
+
function unwrapError (e) {
|
|
47
|
+
return e?.originalError ?? e?.error ?? e?.original_error ?? e
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Unwrap common SOAP error shapes and produce a useful one-line description.
|
|
52
|
+
*
|
|
53
|
+
* The `soap` library and various intermediate layers throw errors in
|
|
54
|
+
* inconsistent shapes — sometimes a real Error with `.message`, sometimes a
|
|
55
|
+
* plain object whose Error lives under `.originalError`, `.error`, or
|
|
56
|
+
* `.original_error`, and sometimes the raw SOAP fault object (no `.message`,
|
|
57
|
+
* but the human-readable text is at `.root.Envelope.Body.Fault.faultstring`).
|
|
58
|
+
* Without unwrapping, `${err.message}` interpolates as `"undefined"` and a
|
|
59
|
+
* retry/failure log tells you nothing about why the call actually failed.
|
|
60
|
+
*
|
|
61
|
+
* Falls back through: unwrap → message → SOAP fault string → error code →
|
|
62
|
+
* JSON dump (truncated) → "unknown".
|
|
63
|
+
*
|
|
64
|
+
* @param {any} err The thrown value (real Error, wrapped object, or plain SOAP fault).
|
|
65
|
+
* @returns {string} A short human-readable description, never undefined.
|
|
66
|
+
*/
|
|
67
|
+
function describeError (err) {
|
|
68
|
+
if (err == null) return 'unknown'
|
|
69
|
+
const e = unwrapError(err)
|
|
70
|
+
if (e == null) return 'unknown'
|
|
71
|
+
return e?.message ||
|
|
72
|
+
e?.root?.Envelope?.Body?.Fault?.faultstring ||
|
|
73
|
+
e?.code ||
|
|
74
|
+
(typeof e === 'string' ? e : null) ||
|
|
75
|
+
(() => { try { return JSON.stringify(e)?.slice(0, 200) } catch { return null } })() ||
|
|
76
|
+
'unknown'
|
|
77
|
+
}
|
|
78
|
+
|
|
35
79
|
// Handle specifically timeout-related unhandled promise rejections as fallback
|
|
36
80
|
process.on('unhandledRejection', (reason, promise) => {
|
|
37
|
-
|
|
81
|
+
const r = unwrapError(reason)
|
|
82
|
+
if (r?.code === 'ECONNABORTED' && (r?.message?.includes('timeout') || r?.message?.includes('exceeded'))) {
|
|
38
83
|
return
|
|
39
84
|
}
|
|
40
85
|
|
|
41
86
|
// Log other unhandled rejections as they indicate real problems
|
|
42
|
-
console.error('Unhandled promise rejection:', reason
|
|
87
|
+
console.error('Unhandled promise rejection:', describeError(reason))
|
|
43
88
|
})
|
|
44
89
|
|
|
45
90
|
// This URL is used to create the SOAP client.
|
|
@@ -71,7 +116,38 @@ const obs = new PerformanceObserver((items) => {
|
|
|
71
116
|
obs.observe({ entryTypes: ['measure'] })
|
|
72
117
|
|
|
73
118
|
async function logSoapError (e) {
|
|
74
|
-
console.error(e
|
|
119
|
+
console.error(describeError(e))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* If `e` carries a SOAP fault (after one level of wrapper unwrap), return a
|
|
124
|
+
* new Error whose `.message` is the friendly `"SOAP Fault: <faultstring>"` and
|
|
125
|
+
* whose structured fault fields are preserved as own properties. Otherwise
|
|
126
|
+
* return `e` unchanged.
|
|
127
|
+
*
|
|
128
|
+
* Why: the proxy catch used to throw `new Error("SOAP Fault: ...")` which
|
|
129
|
+
* stripped `.faultstring`, `.detail`, `.root`, `.response`, and `.body` — and
|
|
130
|
+
* downstream code in `appsuite-codeceptjs` pattern-matches on those exact
|
|
131
|
+
* fields (e.g. `err.faultstring` to ignore "No such user"). Preserving them
|
|
132
|
+
* keeps those consumer paths working while still giving operators a clean
|
|
133
|
+
* one-line message.
|
|
134
|
+
*
|
|
135
|
+
* @param {any} e
|
|
136
|
+
* @returns {any}
|
|
137
|
+
*/
|
|
138
|
+
function wrapSoapFault (e) {
|
|
139
|
+
const unwrapped = unwrapError(e)
|
|
140
|
+
const fault = unwrapped?.root?.Envelope?.Body?.Fault
|
|
141
|
+
if (!fault) return e
|
|
142
|
+
const wrapped = new Error(`SOAP Fault: ${fault.faultstring}`)
|
|
143
|
+
return Object.assign(wrapped, {
|
|
144
|
+
faultstring: fault.faultstring,
|
|
145
|
+
faultcode: fault.faultcode,
|
|
146
|
+
detail: fault.detail,
|
|
147
|
+
root: unwrapped.root,
|
|
148
|
+
response: unwrapped.response,
|
|
149
|
+
body: unwrapped.body
|
|
150
|
+
})
|
|
75
151
|
}
|
|
76
152
|
|
|
77
153
|
/**
|
|
@@ -80,18 +156,21 @@ async function logSoapError (e) {
|
|
|
80
156
|
* @returns {boolean} True if the error should abort the retry, false otherwise.
|
|
81
157
|
**/
|
|
82
158
|
function shouldAbortRetry (error) {
|
|
159
|
+
// Same unwrapping rules as describeError — the real SOAP fault may live
|
|
160
|
+
// one layer deep.
|
|
161
|
+
const err = unwrapError(error)
|
|
83
162
|
try {
|
|
84
163
|
// Handle timeout errors - these should NOT abort retry, let them retry
|
|
85
|
-
if (
|
|
164
|
+
if (err?.code === 'ETIMEDOUT' || err?.code === 'ECONNRESET' || err?.code === 'ECONNABORTED' || err?.message?.includes('timeout')) {
|
|
86
165
|
return false // Allow retry for timeout errors
|
|
87
166
|
}
|
|
88
167
|
|
|
89
168
|
// Handle cases where error doesn't have the expected structure
|
|
90
|
-
if (!
|
|
169
|
+
if (!err?.root?.Envelope?.Body?.Fault) {
|
|
91
170
|
return false // Let it retry for unexpected error structures
|
|
92
171
|
}
|
|
93
172
|
|
|
94
|
-
const fault =
|
|
173
|
+
const fault = err.root.Envelope.Body.Fault
|
|
95
174
|
const details = fault.detail
|
|
96
175
|
const blockedFaultStrings = [
|
|
97
176
|
/Context \d+ already exists/,
|
|
@@ -173,7 +252,7 @@ async function createClientAsync (type) {
|
|
|
173
252
|
randomize: true, // Add jitter to spread out concurrent retries
|
|
174
253
|
onFailedAttempt: async error => {
|
|
175
254
|
if (shouldAbortRetry(error)) throw new AbortError(error)
|
|
176
|
-
console.log(`Retrying ${String(prop)} in ${error.retriesLeft} attempts (${error
|
|
255
|
+
console.log(`Retrying ${String(prop)} in ${error.retriesLeft} attempts (${describeError(error)})`)
|
|
177
256
|
}
|
|
178
257
|
})
|
|
179
258
|
|
|
@@ -183,11 +262,7 @@ async function createClientAsync (type) {
|
|
|
183
262
|
if (!result || !result[0]) return
|
|
184
263
|
return result[0]?.return
|
|
185
264
|
} catch (e) {
|
|
186
|
-
|
|
187
|
-
if (soapError) {
|
|
188
|
-
throw new Error(`SOAP Fault: ${soapError.faultstring}`)
|
|
189
|
-
}
|
|
190
|
-
throw e
|
|
265
|
+
throw wrapSoapFault(e)
|
|
191
266
|
}
|
|
192
267
|
}
|
|
193
268
|
} else {
|
|
@@ -197,4 +272,4 @@ async function createClientAsync (type) {
|
|
|
197
272
|
})
|
|
198
273
|
}
|
|
199
274
|
|
|
200
|
-
export { createClientAsync, logSoapError }
|
|
275
|
+
export { createClientAsync, logSoapError, describeError, shouldAbortRetry, wrapSoapFault, unwrapError }
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Copyright (c) Open-Xchange GmbH, Germany <info@open-xchange.com>
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// soap.js reads PROVISIONING_URL at module load to build the SOAP endpoint URL.
|
|
7
|
+
// We don't exercise that path here; set a stub so the import doesn't crash.
|
|
8
|
+
process.env.PROVISIONING_URL = 'http://soap.test/'
|
|
9
|
+
|
|
10
|
+
const { describeError, shouldAbortRetry, wrapSoapFault } = await import('../soap.js')
|
|
11
|
+
|
|
12
|
+
// Build a SOAP fault object matching the real shape (from soap@1.9.x).
|
|
13
|
+
function soapFault (faultstring, detail = {}) {
|
|
14
|
+
return {
|
|
15
|
+
root: { Envelope: { Body: { Fault: { faultstring, faultcode: 'soap:Server', detail } } } },
|
|
16
|
+
response: { status: 500, statusText: 'Internal Server Error' },
|
|
17
|
+
body: '<soap:Envelope>...</soap:Envelope>'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Build the wrapped shape that crashed the retry log: { error: <Error w/ SOAP fault props> }
|
|
22
|
+
function wrappedFault (faultstring, detail = {}) {
|
|
23
|
+
const err = new Error(`soap:Server: ${faultstring}`)
|
|
24
|
+
Object.assign(err, soapFault(faultstring, detail))
|
|
25
|
+
return { error: err }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('describeError', () => {
|
|
29
|
+
it('returns the message of a plain Error', () => {
|
|
30
|
+
expect(describeError(new Error('boom'))).toBe('boom')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('unwraps { originalError: <Error> } and returns the inner message', () => {
|
|
34
|
+
const inner = new Error('inner-msg')
|
|
35
|
+
expect(describeError({ originalError: inner })).toBe('inner-msg')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('unwraps { error: <Error> } (the shape that produced "(undefined)" in retry logs)', () => {
|
|
39
|
+
const inner = new Error('inner-msg')
|
|
40
|
+
expect(describeError({ error: inner })).toBe('inner-msg')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('falls back to the SOAP faultstring when the wrapped Error has none', () => {
|
|
44
|
+
// Real-world: soap@1.9.x sometimes throws an Error whose .message lives
|
|
45
|
+
// *inside* the SOAP fault rather than on the Error itself. Verify the
|
|
46
|
+
// faultstring path kicks in.
|
|
47
|
+
const fault = soapFault('Could not acquire claim for context 2572225')
|
|
48
|
+
// strip the message so the .root.Envelope... path is the only signal
|
|
49
|
+
const e = Object.assign(Object.create(Error.prototype), fault)
|
|
50
|
+
expect(describeError(e)).toBe('Could not acquire claim for context 2572225')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('falls back to the SOAP faultstring through one level of { error: ... } wrapping', () => {
|
|
54
|
+
const wrapped = wrappedFault('Context 99 already exists')
|
|
55
|
+
// Stripping the inner message forces the faultstring fallback.
|
|
56
|
+
delete wrapped.error.message
|
|
57
|
+
expect(describeError(wrapped)).toBe('Context 99 already exists')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns the error code for timeout-shaped errors (no message, no fault)', () => {
|
|
61
|
+
const e = { code: 'ETIMEDOUT' }
|
|
62
|
+
expect(describeError(e)).toBe('ETIMEDOUT')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns a stringified preview if nothing else matches', () => {
|
|
66
|
+
const e = { weird: 'shape', no: 'normal fields' }
|
|
67
|
+
const out = describeError(e)
|
|
68
|
+
expect(out).toMatch(/weird/)
|
|
69
|
+
expect(out.length).toBeLessThanOrEqual(200)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns "unknown" rather than the literal string "undefined" for empty input', () => {
|
|
73
|
+
expect(describeError(undefined)).toBe('unknown')
|
|
74
|
+
expect(describeError(null)).toBe('unknown')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('handles strings thrown directly', () => {
|
|
78
|
+
expect(describeError('plain string error')).toBe('plain string error')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('unwraps { original_error: <Error> } (snake_case from PropagatedError chains)', () => {
|
|
82
|
+
const inner = new Error('inner-msg')
|
|
83
|
+
expect(describeError({ original_error: inner })).toBe('inner-msg')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('falls back to the SOAP faultstring through one level of { original_error: ... } wrapping', () => {
|
|
87
|
+
const inner = Object.assign(Object.create(Error.prototype), soapFault('Context 7 already exists'))
|
|
88
|
+
expect(describeError({ original_error: inner })).toBe('Context 7 already exists')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('shouldAbortRetry', () => {
|
|
93
|
+
it('does NOT abort on ETIMEDOUT / ECONNRESET / ECONNABORTED — these are retryable', () => {
|
|
94
|
+
expect(shouldAbortRetry({ code: 'ETIMEDOUT' })).toBe(false)
|
|
95
|
+
expect(shouldAbortRetry({ code: 'ECONNRESET' })).toBe(false)
|
|
96
|
+
expect(shouldAbortRetry({ code: 'ECONNABORTED' })).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does NOT abort on transient SOAP faults (e.g. context-claim conflict) — they retry until the lock frees', () => {
|
|
100
|
+
// This is the actual fault from the user's report — we *want* the retry
|
|
101
|
+
// loop to keep trying until the conflicting writer commits.
|
|
102
|
+
const e = soapFault('Could not acquire claim for context 2572225 due to another conflicting provisioning operation. Please try again later.')
|
|
103
|
+
expect(shouldAbortRetry(e)).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('aborts on "Context N already exists" — re-running will keep failing', () => {
|
|
107
|
+
const e = soapFault('Context 4242 already exists')
|
|
108
|
+
expect(shouldAbortRetry(e)).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('aborts on "Authentication failed"', () => {
|
|
112
|
+
const e = soapFault('Authentication failed')
|
|
113
|
+
expect(shouldAbortRetry(e)).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('aborts on blocked exceptions in the fault detail (e.g. ContextExistsException)', () => {
|
|
117
|
+
const e = soapFault('some message', { ContextExistsException: {} })
|
|
118
|
+
expect(shouldAbortRetry(e)).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('looks through one level of { error: ... } wrapping when probing the fault', () => {
|
|
122
|
+
const wrapped = wrappedFault('Context 4242 already exists')
|
|
123
|
+
expect(shouldAbortRetry(wrapped)).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('looks through { originalError: ... } wrapping too', () => {
|
|
127
|
+
const inner = new Error('whatever')
|
|
128
|
+
Object.assign(inner, soapFault('Authentication failed'))
|
|
129
|
+
expect(shouldAbortRetry({ originalError: inner })).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('looks through { original_error: ... } (snake_case) wrapping too', () => {
|
|
133
|
+
const inner = new Error('whatever')
|
|
134
|
+
Object.assign(inner, soapFault('Context 4242 already exists'))
|
|
135
|
+
expect(shouldAbortRetry({ original_error: inner })).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('does NOT abort on completely unknown error structures — retry and let it sort itself out', () => {
|
|
139
|
+
expect(shouldAbortRetry({ random: 'object' })).toBe(false)
|
|
140
|
+
expect(shouldAbortRetry(undefined)).toBe(false)
|
|
141
|
+
expect(shouldAbortRetry(null)).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('wrapSoapFault', () => {
|
|
146
|
+
it('returns the input unchanged when no SOAP fault is present', () => {
|
|
147
|
+
const e = new Error('plain timeout')
|
|
148
|
+
expect(wrapSoapFault(e)).toBe(e)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('returns the input unchanged for non-SOAP wrapped errors (e.g. axios timeout)', () => {
|
|
152
|
+
const e = Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })
|
|
153
|
+
expect(wrapSoapFault({ error: e })).toEqual({ error: e })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns a NEW Error with the fault structure preserved on it', () => {
|
|
157
|
+
// Real-world: the soap library hands us an Error whose .root/.response/.body
|
|
158
|
+
// carry the structured fault. The proxy catch used to throw a plain new
|
|
159
|
+
// Error("SOAP Fault: …"), which DELETED the structured fields and broke
|
|
160
|
+
// downstream consumers that pattern-match on err.faultstring.
|
|
161
|
+
const inner = Object.assign(new Error('original'), soapFault('Context 42 already exists', { ContextExistsException: {} }))
|
|
162
|
+
const out = wrapSoapFault(inner)
|
|
163
|
+
expect(out).not.toBe(inner) // it's a new Error so the message can be friendly
|
|
164
|
+
expect(out).toBeInstanceOf(Error)
|
|
165
|
+
expect(out.message).toBe('SOAP Fault: Context 42 already exists')
|
|
166
|
+
expect(out.faultstring).toBe('Context 42 already exists')
|
|
167
|
+
expect(out.faultcode).toBe('soap:Server')
|
|
168
|
+
expect(out.detail).toEqual({ ContextExistsException: {} })
|
|
169
|
+
expect(out.root).toBe(inner.root)
|
|
170
|
+
expect(out.response).toBe(inner.response)
|
|
171
|
+
expect(out.body).toBe(inner.body)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('unwraps { error: <Error w/ fault> } before extracting the fault', () => {
|
|
175
|
+
const inner = Object.assign(new Error('original'), soapFault('Authentication failed'))
|
|
176
|
+
const out = wrapSoapFault({ error: inner })
|
|
177
|
+
expect(out.faultstring).toBe('Authentication failed')
|
|
178
|
+
expect(out.message).toBe('SOAP Fault: Authentication failed')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('unwraps { original_error: <Error w/ fault> } (snake_case) before extracting the fault', () => {
|
|
182
|
+
const inner = Object.assign(new Error('original'), soapFault('No such user'))
|
|
183
|
+
const out = wrapSoapFault({ original_error: inner })
|
|
184
|
+
expect(out.faultstring).toBe('No such user')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// Regression: the exact shape from the original incident report — a transient
|
|
189
|
+
// "Could not acquire claim" fault wrapped in soap@1.9.x's `{ error: <Error> }`
|
|
190
|
+
// envelope, with the StorageException nested in detail. Three concerns checked
|
|
191
|
+
// together so the next person debugging this flow can see the whole pipeline
|
|
192
|
+
// pass against the real symptom in one place.
|
|
193
|
+
describe('real-world: transient "Could not acquire claim" fault wrapped in { error: ... }', () => {
|
|
194
|
+
const faultstring = 'Could not acquire claim for context 2572225 due to another conflicting provisioning operation. Please try again later.; exceptionId -1132283668-9930'
|
|
195
|
+
function realWorldThrownValue () {
|
|
196
|
+
const err = new Error(`soap:Server: ${faultstring}`)
|
|
197
|
+
Object.assign(err, soapFault(faultstring, { StorageException: { StorageException: null } }))
|
|
198
|
+
return { error: err }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
it('describeError produces a useful retry-log line (not "(undefined)")', () => {
|
|
202
|
+
const out = describeError(realWorldThrownValue())
|
|
203
|
+
expect(out).toContain('Could not acquire claim for context 2572225')
|
|
204
|
+
expect(out).not.toBe('undefined')
|
|
205
|
+
expect(out).not.toBe('unknown')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('shouldAbortRetry returns false — this is transient, keep retrying', () => {
|
|
209
|
+
// StorageException is NOT in the blockedExceptions list, and the
|
|
210
|
+
// faultstring doesn't match any blockedFaultStrings regex, so the retry
|
|
211
|
+
// loop must keep going until the conflicting writer commits.
|
|
212
|
+
expect(shouldAbortRetry(realWorldThrownValue())).toBe(false)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('wrapSoapFault preserves .faultstring, .detail.StorageException, .root, .response, .body', () => {
|
|
216
|
+
const out = wrapSoapFault(realWorldThrownValue())
|
|
217
|
+
expect(out).toBeInstanceOf(Error)
|
|
218
|
+
expect(out.message).toMatch(/^SOAP Fault: Could not acquire claim for context 2572225/)
|
|
219
|
+
expect(out.faultstring).toContain('Could not acquire claim for context 2572225')
|
|
220
|
+
expect(out.faultcode).toBe('soap:Server')
|
|
221
|
+
expect(out.detail).toEqual({ StorageException: { StorageException: null } })
|
|
222
|
+
expect(out.root.Envelope.Body.Fault.faultstring).toContain('Could not acquire')
|
|
223
|
+
expect(out.response.status).toBe(500)
|
|
224
|
+
expect(out.body).toMatch(/soap:Envelope/)
|
|
225
|
+
})
|
|
226
|
+
})
|