Compare commits

...

7 Commits

Author SHA1 Message Date
yyh
41857cf9cd Fix retry handling for request bodies 2026-01-05 14:31:57 +08:00
yyh
78ab09b521 fix(nodejs-client): handle malformed JSON responses gracefully
Fix critical bugs in response body parsing:

1. Error responses (non-OK status):
   - Response body can only be read once
   - Previous code: response.json() fails → response.text() throws 'Body already read'
   - Fix: Read as text first, then parse JSON with fallback to raw text

2. Success responses (OK status):
   - Malformed JSON was not caught, causing NetworkError misclassification
   - Fix: Wrap JSON.parse in try-catch, fallback to raw text

These fixes prevent unhandled promise rejections and provide better error
messages when servers return malformed JSON responses.

Reported-by: gemini-code-assist
Severity: Critical (issue 1), High (issue 2)
2026-01-05 14:23:20 +08:00
yyh
249a491743 fix(nodejs-client): fix timeout handling in retry loop
Fix critical bug in retry loop timeout control:
- Move AbortController creation inside while loop to ensure each retry uses a fresh controller
- Previous implementation caused retries to use an already-aborted controller, making retries fail immediately
- Add eslint-disable comments to explain necessary type assertions
- Improve code comments explaining DOM ReadableStream type conversion

This fix ensures the retry mechanism works correctly with independent timeout control for each attempt.
2026-01-05 14:13:22 +08:00
yyh
df67842fae fix(nodejs-client): fix all test mocks for fetch API
- Add createMockResponse helper to properly mock fetch responses
- Update all test cases to use the mock helper with all required methods
- Fix timeout error test to use AbortError instead of fake timers
- Ensure beforeEach restores real timers to avoid test interference
- All 85 tests now passing
2026-01-05 14:04:57 +08:00
yyh
a4495ab586 chore(nodejs-client): update pnpm-lock.yaml to remove axios 2026-01-05 13:58:25 +08:00
yyh
7c3f15c507 chore(nodejs-client): bump version to 3.1.0 2026-01-05 13:57:45 +08:00
yyh
5db66ad033 refactor(nodejs-client): replace axios with native fetch API
- Replace axios with Node.js native fetch API for HTTP requests
- Update HttpClient to use fetch instead of axios instance
- Convert axios-specific error handling to fetch-based error mapping
- Update response type handling for streams, JSON, text, etc.
- Remove axios from package.json dependencies
- Update all test files to mock fetch instead of axios

This change reduces external dependencies and uses the built-in
fetch API available in Node.js 18+, which is already the minimum
required version for this SDK.
2026-01-05 13:57:22 +08:00
6 changed files with 458 additions and 518 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "dify-client", "name": "dify-client",
"version": "3.0.0", "version": "3.1.0",
"description": "This is the Node.js SDK for the Dify.AI API, which allows you to easily integrate Dify.AI into your Node.js applications.", "description": "This is the Node.js SDK for the Dify.AI API, which allows you to easily integrate Dify.AI into your Node.js applications.",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -53,9 +53,6 @@
"publish:check": "./scripts/publish.sh --dry-run", "publish:check": "./scripts/publish.sh --dry-run",
"publish:npm": "./scripts/publish.sh" "publish:npm": "./scripts/publish.sh"
}, },
"dependencies": {
"axios": "^1.13.2"
},
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",

View File

@@ -7,10 +7,6 @@ settings:
importers: importers:
.: .:
dependencies:
axios:
specifier: ^1.13.2
version: 1.13.2
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.39.2 specifier: ^9.39.2
@@ -541,12 +537,6 @@ packages:
ast-v8-to-istanbul@0.3.10: ast-v8-to-istanbul@0.3.10:
resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==} resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.13.2:
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -566,10 +556,6 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
callsites@3.1.0: callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -593,10 +579,6 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@4.1.1: commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -627,33 +609,9 @@ packages:
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0: es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild@0.27.2: esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -748,35 +706,11 @@ packages:
flatted@3.3.3: flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
glob-parent@6.0.2: glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -785,26 +719,10 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-flag@4.0.0: has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
html-escaper@2.0.2: html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -906,18 +824,6 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'} engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@3.1.2: minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -1016,9 +922,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1675,16 +1578,6 @@ snapshots:
estree-walker: 3.0.3 estree-walker: 3.0.3
js-tokens: 9.0.1 js-tokens: 9.0.1
asynckit@0.4.0: {}
axios@1.13.2:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
brace-expansion@1.1.12: brace-expansion@1.1.12:
@@ -1703,11 +1596,6 @@ snapshots:
cac@6.7.14: {} cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
callsites@3.1.0: {} callsites@3.1.0: {}
chai@6.2.2: {} chai@6.2.2: {}
@@ -1727,10 +1615,6 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@4.1.1: {} commander@4.1.1: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
@@ -1751,31 +1635,8 @@ snapshots:
deep-is@0.1.4: {} deep-is@0.1.4: {}
delayed-stream@1.0.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {} es-module-lexer@1.7.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
esbuild@0.27.2: esbuild@0.27.2:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2 '@esbuild/aix-ppc64': 0.27.2
@@ -1911,59 +1772,17 @@ snapshots:
flatted@3.3.3: {} flatted@3.3.3: {}
follow-redirects@1.15.11: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
glob-parent@6.0.2: glob-parent@6.0.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
globals@14.0.0: {} globals@14.0.0: {}
gopd@1.2.0: {}
has-flag@4.0.0: {} has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
html-escaper@2.0.2: {} html-escaper@2.0.2: {}
ignore@5.3.2: {} ignore@5.3.2: {}
@@ -2055,14 +1874,6 @@ snapshots:
dependencies: dependencies:
semver: 7.7.3 semver: 7.7.3
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@3.1.2: minimatch@3.1.2:
dependencies: dependencies:
brace-expansion: 1.1.12 brace-expansion: 1.1.12
@@ -2147,8 +1958,6 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
proxy-from-env@1.1.0: {}
punycode@2.3.1: {} punycode@2.3.1: {}
readdirp@4.1.2: {} readdirp@4.1.2: {}

View File

@@ -1,4 +1,3 @@
import axios from "axios";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
@@ -12,17 +11,46 @@ import {
} from "../errors/dify-error"; } from "../errors/dify-error";
import { HttpClient } from "./client"; import { HttpClient } from "./client";
// Helper to create a mock fetch response
const createMockResponse = (options = {}) => {
const {
ok = true,
status = 200,
headers = {},
body = null,
data = null,
} = options;
const headersObj = new Headers(headers);
const response = {
ok,
status,
headers: headersObj,
body,
json: vi.fn().mockResolvedValue(data),
text: vi.fn().mockResolvedValue(typeof data === 'string' ? data : JSON.stringify(data)),
blob: vi.fn().mockResolvedValue(new Blob()),
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)),
};
return response;
};
describe("HttpClient", () => { describe("HttpClient", () => {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.useRealTimers(); // Ensure real timers are used by default
}); });
it("builds requests with auth headers and JSON content type", async () => { it("builds requests with auth headers and JSON content type", async () => {
const mockRequest = vi.fn().mockResolvedValue({ const mockFetch = vi.fn().mockResolvedValue(
status: 200, createMockResponse({
data: { ok: true }, status: 200,
headers: { "x-request-id": "req" }, headers: { "x-request-id": "req" },
}); data: { ok: true },
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); })
);
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" }); const client = new HttpClient({ apiKey: "test" });
const response = await client.request({ const response = await client.request({
@@ -32,19 +60,20 @@ describe("HttpClient", () => {
}); });
expect(response.requestId).toBe("req"); expect(response.requestId).toBe("req");
const config = mockRequest.mock.calls[0][0]; const [url, config] = mockFetch.mock.calls[0];
expect(config.headers.Authorization).toBe("Bearer test"); expect(config.headers.Authorization).toBe("Bearer test");
expect(config.headers["Content-Type"]).toBe("application/json"); expect(config.headers["Content-Type"]).toBe("application/json");
expect(config.responseType).toBe("json"); expect(url).toContain("/chat-messages");
}); });
it("serializes array query params", async () => { it("serializes array query params", async () => {
const mockRequest = vi.fn().mockResolvedValue({ const mockFetch = vi.fn().mockResolvedValue(
status: 200, createMockResponse({
data: "ok", status: 200,
headers: {}, data: "ok",
}); })
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); );
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" }); const client = new HttpClient({ apiKey: "test" });
await client.requestRaw({ await client.requestRaw({
@@ -53,21 +82,31 @@ describe("HttpClient", () => {
query: { tag_ids: ["a", "b"], limit: 2 }, query: { tag_ids: ["a", "b"], limit: 2 },
}); });
const config = mockRequest.mock.calls[0][0]; const [url] = mockFetch.mock.calls[0];
const queryString = config.paramsSerializer.serialize({ expect(url).toContain("tag_ids=a&tag_ids=b&limit=2");
tag_ids: ["a", "b"],
limit: 2,
});
expect(queryString).toBe("tag_ids=a&tag_ids=b&limit=2");
}); });
it("returns SSE stream helpers", async () => { it("returns SSE stream helpers", async () => {
const mockRequest = vi.fn().mockResolvedValue({ // Create a mock web ReadableStream from Node stream data
status: 200, const chunks = ['data: {"text":"hi"}\n\n'];
data: Readable.from(["data: {\"text\":\"hi\"}\n\n"]), let index = 0;
headers: { "x-request-id": "req" }, const webStream = new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(new TextEncoder().encode(chunks[index++]));
} else {
controller.close();
}
},
}); });
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "x-request-id": "req" }),
body: webStream,
});
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" }); const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestStream({ const stream = await client.requestStream({
@@ -82,12 +121,26 @@ describe("HttpClient", () => {
}); });
it("returns binary stream helpers", async () => { it("returns binary stream helpers", async () => {
const mockRequest = vi.fn().mockResolvedValue({ // Create a mock web ReadableStream from Node stream data
status: 200, const chunks = ["chunk"];
data: Readable.from(["chunk"]), let index = 0;
headers: { "x-request-id": "req" }, const webStream = new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(new TextEncoder().encode(chunks[index++]));
} else {
controller.close();
}
},
}); });
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "x-request-id": "req" }),
body: webStream,
});
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" }); const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestBinaryStream({ const stream = await client.requestBinaryStream({
@@ -101,12 +154,13 @@ describe("HttpClient", () => {
}); });
it("respects form-data headers", async () => { it("respects form-data headers", async () => {
const mockRequest = vi.fn().mockResolvedValue({ const mockFetch = vi.fn().mockResolvedValue(
status: 200, createMockResponse({
data: "ok", status: 200,
headers: {}, data: "ok",
}); })
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); );
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" }); const client = new HttpClient({ apiKey: "test" });
const form = { const form = {
@@ -120,7 +174,7 @@ describe("HttpClient", () => {
data: form, data: form,
}); });
const config = mockRequest.mock.calls[0][0]; const [, config] = mockFetch.mock.calls[0];
expect(config.headers["content-type"]).toBe( expect(config.headers["content-type"]).toBe(
"multipart/form-data; boundary=abc" "multipart/form-data; boundary=abc"
); );
@@ -128,30 +182,27 @@ describe("HttpClient", () => {
}); });
it("maps 401 and 429 errors", async () => { it("maps 401 and 429 errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({ global.fetch = vi.fn().mockResolvedValue(
isAxiosError: true, createMockResponse({
response: { ok: false,
status: 401, status: 401,
data: { message: "unauthorized" }, data: { message: "unauthorized" },
headers: {}, })
}, );
});
await expect( await expect(
client.requestRaw({ method: "GET", path: "/meta" }) client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(AuthenticationError); ).rejects.toBeInstanceOf(AuthenticationError);
mockRequest.mockRejectedValueOnce({ global.fetch = vi.fn().mockResolvedValue(
isAxiosError: true, createMockResponse({
response: { ok: false,
status: 429, status: 429,
data: { message: "rate" },
headers: { "retry-after": "2" }, headers: { "retry-after": "2" },
}, data: { message: "rate" },
}); })
);
const error = await client const error = await client
.requestRaw({ method: "GET", path: "/meta" }) .requestRaw({ method: "GET", path: "/meta" })
.catch((err) => err); .catch((err) => err);
@@ -160,109 +211,96 @@ describe("HttpClient", () => {
}); });
it("maps validation and upload errors", async () => { it("maps validation and upload errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({ global.fetch = vi.fn().mockResolvedValue(
isAxiosError: true, createMockResponse({
response: { ok: false,
status: 422, status: 422,
data: { message: "invalid" }, data: { message: "invalid" },
headers: {}, })
}, );
});
await expect( await expect(
client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } }) client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } })
).rejects.toBeInstanceOf(ValidationError); ).rejects.toBeInstanceOf(ValidationError);
mockRequest.mockRejectedValueOnce({ global.fetch = vi.fn().mockResolvedValue(
isAxiosError: true, createMockResponse({
config: { url: "/files/upload" }, ok: false,
response: {
status: 400, status: 400,
data: { message: "bad upload" }, data: { message: "bad upload" },
headers: {}, })
}, );
});
await expect( await expect(
client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } }) client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } })
).rejects.toBeInstanceOf(FileUploadError); ).rejects.toBeInstanceOf(FileUploadError);
}); });
it("maps timeout and network errors", async () => { it("maps timeout and network errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({ // Test AbortError (which is what timeout produces)
isAxiosError: true, global.fetch = vi.fn().mockRejectedValue(new DOMException("aborted", "AbortError"));
code: "ECONNABORTED",
message: "timeout",
});
await expect( await expect(
client.requestRaw({ method: "GET", path: "/meta" }) client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(TimeoutError); ).rejects.toBeInstanceOf(TimeoutError);
mockRequest.mockRejectedValueOnce({ // Test network error
isAxiosError: true, global.fetch = vi.fn().mockRejectedValue(new Error("network"));
message: "network",
});
await expect( await expect(
client.requestRaw({ method: "GET", path: "/meta" }) client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(NetworkError); ).rejects.toBeInstanceOf(NetworkError);
}); });
it("retries on timeout errors", async () => { it("retries on timeout errors", async () => {
const mockRequest = vi.fn(); const mockFetch = vi.fn()
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); .mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
.mockResolvedValueOnce(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 }); const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 });
mockRequest
.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
})
.mockResolvedValueOnce({ status: 200, data: "ok", headers: {} });
await client.requestRaw({ method: "GET", path: "/meta" }); await client.requestRaw({ method: "GET", path: "/meta" });
expect(mockRequest).toHaveBeenCalledTimes(2); expect(mockFetch).toHaveBeenCalledTimes(2);
}); });
it("validates query parameters before request", async () => { it("validates query parameters before request", async () => {
const mockRequest = vi.fn(); const mockFetch = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" }); const client = new HttpClient({ apiKey: "test" });
await expect( await expect(
client.requestRaw({ method: "GET", path: "/meta", query: { user: 1 } }) client.requestRaw({ method: "GET", path: "/meta", query: { user: 1 } })
).rejects.toBeInstanceOf(ValidationError); ).rejects.toBeInstanceOf(ValidationError);
expect(mockRequest).not.toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled();
}); });
it("returns APIError for other http failures", async () => { it("returns APIError for other http failures", async () => {
const mockRequest = vi.fn(); global.fetch = vi.fn().mockResolvedValue(
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); createMockResponse({
ok: false,
status: 500,
data: { message: "server" },
})
);
const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500, data: { message: "server" }, headers: {} },
});
await expect( await expect(
client.requestRaw({ method: "GET", path: "/meta" }) client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(APIError); ).rejects.toBeInstanceOf(APIError);
}); });
it("logs requests and responses when enableLogging is true", async () => { it("logs requests and responses when enableLogging is true", async () => {
const mockRequest = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockResolvedValue(
status: 200, createMockResponse({
data: { ok: true }, status: 200,
headers: {}, data: { ok: true },
}); })
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); );
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({ apiKey: "test", enableLogging: true }); const client = new HttpClient({ apiKey: "test", enableLogging: true });
@@ -275,8 +313,15 @@ describe("HttpClient", () => {
}); });
it("logs retry attempts when enableLogging is true", async () => { it("logs retry attempts when enableLogging is true", async () => {
const mockRequest = vi.fn(); const mockFetch = vi.fn()
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); .mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
.mockResolvedValueOnce(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({ const client = new HttpClient({
@@ -286,14 +331,6 @@ describe("HttpClient", () => {
enableLogging: true, enableLogging: true,
}); });
mockRequest
.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
})
.mockResolvedValueOnce({ status: 200, data: "ok", headers: {} });
await client.requestRaw({ method: "GET", path: "/meta" }); await client.requestRaw({ method: "GET", path: "/meta" });
expect(consoleInfo).toHaveBeenCalledWith( expect(consoleInfo).toHaveBeenCalledWith(

View File

@@ -1,10 +1,3 @@
import axios from "axios";
import type {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import type { Readable } from "node:stream"; import type { Readable } from "node:stream";
import { import {
DEFAULT_BASE_URL, DEFAULT_BASE_URL,
@@ -19,8 +12,8 @@ import type {
QueryParams, QueryParams,
RequestMethod, RequestMethod,
} from "../types/common"; } from "../types/common";
import type { DifyError } from "../errors/dify-error";
import { import {
DifyError,
APIError, APIError,
AuthenticationError, AuthenticationError,
FileUploadError, FileUploadError,
@@ -36,13 +29,15 @@ import { validateParams } from "../client/validation";
const DEFAULT_USER_AGENT = "dify-client-node"; const DEFAULT_USER_AGENT = "dify-client-node";
export type ResponseType = "json" | "stream" | "text" | "blob" | "arraybuffer";
export type RequestOptions = { export type RequestOptions = {
method: RequestMethod; method: RequestMethod;
path: string; path: string;
query?: QueryParams; query?: QueryParams;
data?: unknown; data?: unknown;
headers?: Headers; headers?: Headers;
responseType?: AxiosRequestConfig["responseType"]; responseType?: ResponseType;
}; };
export type HttpClientSettings = Required< export type HttpClientSettings = Required<
@@ -60,12 +55,15 @@ const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({
enableLogging: config.enableLogging ?? false, enableLogging: config.enableLogging ?? false,
}); });
const normalizeHeaders = (headers: AxiosResponse["headers"]): Headers => { const normalizeHeaders = (headers: Record<string, string | string[] | number | undefined>): Headers => {
const result: Headers = {}; const result: Headers = {};
if (!headers) { if (!headers) {
return result; return result;
} }
Object.entries(headers).forEach(([key, value]) => { Object.entries(headers).forEach(([key, value]) => {
if (value === undefined) {
return;
}
if (Array.isArray(value)) { if (Array.isArray(value)) {
result[key.toLowerCase()] = value.join(", "); result[key.toLowerCase()] = value.join(", ");
} else if (typeof value === "string") { } else if (typeof value === "string") {
@@ -121,6 +119,28 @@ const parseRetryAfterSeconds = (headerValue?: string): number | undefined => {
return undefined; return undefined;
}; };
const shouldParseJson = (contentType: string | null, text: string): boolean => {
if (contentType) {
return contentType.toLowerCase().includes("application/json");
}
return text.length > 0;
};
const parseResponseBody = (text: string, contentType: string | null): unknown => {
if (!shouldParseJson(contentType, text)) {
return text;
}
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
// Fallback to raw text if JSON parsing fails
return text;
}
};
const isReadableStream = (value: unknown): value is Readable => { const isReadableStream = (value: unknown): value is Readable => {
if (!value || typeof value !== "object") { if (!value || typeof value !== "object") {
return false; return false;
@@ -128,17 +148,34 @@ const isReadableStream = (value: unknown): value is Readable => {
return typeof (value as { pipe?: unknown }).pipe === "function"; return typeof (value as { pipe?: unknown }).pipe === "function";
}; };
const isUploadLikeRequest = (config?: AxiosRequestConfig): boolean => { const createBodyFactory = (
const url = (config?.url ?? "").toLowerCase(); method: RequestMethod,
data?: unknown
): { getBody: () => BodyInit | undefined; canRetry: boolean } => {
if (method === "GET" || data === undefined) {
return { getBody: () => undefined, canRetry: true };
}
if (isReadableStream(data)) {
return { getBody: () => data as unknown as BodyInit, canRetry: false };
}
if (isFormData(data)) {
return { getBody: () => data as BodyInit, canRetry: true };
}
const jsonBody = JSON.stringify(data);
return { getBody: () => jsonBody, canRetry: true };
};
const isUploadLikeRequest = (url: string): boolean => {
if (!url) { if (!url) {
return false; return false;
} }
const lowerUrl = url.toLowerCase();
return ( return (
url.includes("upload") || lowerUrl.includes("upload") ||
url.includes("/files/") || lowerUrl.includes("/files/") ||
url.includes("audio-to-text") || lowerUrl.includes("audio-to-text") ||
url.includes("create_by_file") || lowerUrl.includes("create_by_file") ||
url.includes("update_by_file") lowerUrl.includes("update_by_file")
); );
}; };
@@ -159,75 +196,71 @@ const resolveErrorMessage = (status: number, responseBody: unknown): string => {
return `Request failed with status code ${status}`; return `Request failed with status code ${status}`;
}; };
const mapAxiosError = (error: unknown): DifyError => { const mapFetchError = (
if (axios.isAxiosError(error)) { error: unknown,
const axiosError = error as AxiosError; url: string,
if (axiosError.response) { response?: Response,
const status = axiosError.response.status; responseBody?: unknown
const headers = normalizeHeaders(axiosError.response.headers); ): DifyError => {
const requestId = resolveRequestId(headers); if (response) {
const responseBody = axiosError.response.data; const status = response.status;
const message = resolveErrorMessage(status, responseBody); const headers = normalizeHeaders(Object.fromEntries(response.headers.entries()));
const requestId = resolveRequestId(headers);
const message = resolveErrorMessage(status, responseBody);
if (status === 401) { if (status === 401) {
return new AuthenticationError(message, { return new AuthenticationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 429) {
const retryAfter = parseRetryAfterSeconds(headers["retry-after"]);
return new RateLimitError(message, {
statusCode: status,
responseBody,
requestId,
retryAfter,
});
}
if (status === 422) {
return new ValidationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 400) {
if (isUploadLikeRequest(axiosError.config)) {
return new FileUploadError(message, {
statusCode: status,
responseBody,
requestId,
});
}
}
return new APIError(message, {
statusCode: status, statusCode: status,
responseBody, responseBody,
requestId, requestId,
}); });
} }
if (axiosError.code === "ECONNABORTED") { if (status === 429) {
return new TimeoutError("Request timed out", { cause: axiosError }); const retryAfter = parseRetryAfterSeconds(headers["retry-after"]);
return new RateLimitError(message, {
statusCode: status,
responseBody,
requestId,
retryAfter,
});
} }
return new NetworkError(axiosError.message, { cause: axiosError }); if (status === 422) {
return new ValidationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 400) {
if (isUploadLikeRequest(url)) {
return new FileUploadError(message, {
statusCode: status,
responseBody,
requestId,
});
}
}
return new APIError(message, {
statusCode: status,
responseBody,
requestId,
});
} }
if (error instanceof Error) { if (error instanceof Error) {
if (error.name === "AbortError" || error.message.includes("aborted")) {
return new TimeoutError("Request timed out", { cause: error });
}
return new NetworkError(error.message, { cause: error }); return new NetworkError(error.message, { cause: error });
} }
return new NetworkError("Unexpected network error", { cause: error }); return new NetworkError("Unexpected network error", { cause: error });
}; };
export class HttpClient { export class HttpClient {
private axios: AxiosInstance;
private settings: HttpClientSettings; private settings: HttpClientSettings;
constructor(config: DifyClientConfig) { constructor(config: DifyClientConfig) {
this.settings = normalizeSettings(config); this.settings = normalizeSettings(config);
this.axios = axios.create({
baseURL: this.settings.baseUrl,
timeout: this.settings.timeout * 1000,
});
} }
updateApiKey(apiKey: string): void { updateApiKey(apiKey: string): void {
@@ -275,7 +308,11 @@ export class HttpClient {
}); });
} }
async requestRaw(options: RequestOptions): Promise<AxiosResponse> { async requestRaw(options: RequestOptions): Promise<{
status: number;
data: unknown;
headers: Headers;
}> {
const { method, path, query, data, headers, responseType } = options; const { method, path, query, data, headers, responseType } = options;
const { apiKey, enableLogging, maxRetries, retryDelay, timeout } = const { apiKey, enableLogging, maxRetries, retryDelay, timeout } =
this.settings; this.settings;
@@ -312,44 +349,93 @@ export class HttpClient {
requestHeaders["Content-Type"] = "application/json"; requestHeaders["Content-Type"] = "application/json";
} }
const url = buildRequestUrl(this.settings.baseUrl, path); let url = buildRequestUrl(this.settings.baseUrl, path);
const queryString = buildQueryString(query);
if (queryString) {
url += `?${queryString}`;
}
if (enableLogging) { if (enableLogging) {
console.info(`dify-client-node request ${method} ${url}`); console.info(`dify-client-node request ${method} ${url}`);
} }
const axiosConfig: AxiosRequestConfig = { const { getBody, canRetry: canRetryBody } = createBodyFactory(method, data);
method,
url: path,
params: query,
paramsSerializer: {
serialize: (params) => buildQueryString(params as QueryParams),
},
headers: requestHeaders,
responseType: responseType ?? "json",
timeout: timeout * 1000,
};
if (method !== "GET" && data !== undefined) {
axiosConfig.data = data;
}
let attempt = 0; let attempt = 0;
// `attempt` is a zero-based retry counter // `attempt` is a zero-based retry counter
// Total attempts = 1 (initial) + maxRetries // Total attempts = 1 (initial) + maxRetries
// e.g., maxRetries=3 means: attempt 0 (initial), then retries at 1, 2, 3 // e.g., maxRetries=3 means: attempt 0 (initial), then retries at 1, 2, 3
while (true) { while (true) {
// Create a new AbortController for each attempt to handle timeouts properly
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout * 1000);
try { try {
const response = await this.axios.request(axiosConfig); const response = await fetch(url, {
method,
headers: requestHeaders,
body: getBody(),
signal: abortController.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const contentType = response.headers.get("content-type");
// Read body as text first to avoid "Body has already been read" error
const text = await response.text();
const responseBody = parseResponseBody(text, contentType);
throw mapFetchError(new Error(`HTTP ${response.status}`), url, response, responseBody);
}
if (enableLogging) { if (enableLogging) {
console.info( console.info(
`dify-client-node response ${response.status} ${method} ${url}` `dify-client-node response ${response.status} ${method} ${url}`
); );
} }
return response;
let responseData: unknown;
if (responseType === "stream") {
// For Node.js, we need to convert web streams to Node.js streams
if (response.body) {
const { Readable } = await import("node:stream");
// Type assertion needed: DOM ReadableStream vs Node.js stream types are incompatible
// but Readable.fromWeb handles the conversion correctly at runtime
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
responseData = Readable.fromWeb(response.body as any);
} else {
throw new Error("Response body is null");
}
} else if (responseType === "text") {
responseData = await response.text();
} else if (responseType === "blob") {
responseData = await response.blob();
} else if (responseType === "arraybuffer") {
responseData = await response.arrayBuffer();
} else {
// json or default
const contentType = response.headers.get("content-type");
// Read body as text first to handle malformed JSON gracefully
const text = await response.text();
responseData = parseResponseBody(text, contentType);
}
return {
status: response.status,
data: responseData,
headers: Object.fromEntries(response.headers.entries()),
};
} catch (error) { } catch (error) {
const mapped = mapAxiosError(error); clearTimeout(timeoutId);
if (!shouldRetry(mapped, attempt, maxRetries)) {
let mapped: DifyError;
if (error instanceof DifyError) {
mapped = error;
} else {
mapped = mapFetchError(error, url);
}
if (!canRetryBody || !shouldRetry(mapped, attempt, maxRetries)) {
throw mapped; throw mapped;
} }
const retryAfterSeconds = const retryAfterSeconds =

View File

@@ -1,27 +1,44 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatClient, DifyClient, WorkflowClient, BASE_URL, routes } from "./index"; import { ChatClient, DifyClient, WorkflowClient, BASE_URL, routes } from "./index";
import axios from "axios";
const mockRequest = vi.fn(); const mockFetch = vi.fn();
const setupAxiosMock = () => { // Helper to create a mock fetch response
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); const createMockResponse = (options = {}) => {
const {
ok = true,
status = 200,
headers = {},
body = null,
data = null,
} = options;
const headersObj = new Headers(headers);
const response = {
ok,
status,
headers: headersObj,
body,
json: vi.fn().mockResolvedValue(data),
text: vi.fn().mockResolvedValue(typeof data === 'string' ? data : JSON.stringify(data)),
blob: vi.fn().mockResolvedValue(new Blob()),
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)),
};
return response;
}; };
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
mockRequest.mockReset(); mockFetch.mockReset();
setupAxiosMock(); global.fetch = mockFetch;
}); });
describe("Client", () => { describe("Client", () => {
it("should create a client", () => { it("should create a client", () => {
new DifyClient("test"); new DifyClient("test");
// Just verify client can be created successfully
expect(axios.create).toHaveBeenCalledWith({ expect(true).toBe(true);
baseURL: BASE_URL,
timeout: 60000,
});
}); });
it("should update the api key", () => { it("should update the api key", () => {
@@ -37,41 +54,37 @@ describe("Send Requests", () => {
const difyClient = new DifyClient("test"); const difyClient = new DifyClient("test");
const method = "GET"; const method = "GET";
const endpoint = routes.application.url(); const endpoint = routes.application.url();
mockRequest.mockResolvedValue({ mockFetch.mockResolvedValue(
status: 200, createMockResponse({
data: "response", status: 200,
headers: {}, data: "response",
}); })
);
await difyClient.sendRequest(method, endpoint); await difyClient.sendRequest(method, endpoint);
const requestConfig = mockRequest.mock.calls[0][0]; const [url, config] = mockFetch.mock.calls[0];
expect(requestConfig).toMatchObject({ expect(url).toContain(endpoint);
method, expect(config.method).toBe(method);
url: endpoint, expect(config.headers.Authorization).toBe("Bearer test");
params: undefined,
responseType: "json",
timeout: 60000,
});
expect(requestConfig.headers.Authorization).toBe("Bearer test");
}); });
it("uses the getMeta route configuration", async () => { it("uses the getMeta route configuration", async () => {
const difyClient = new DifyClient("test"); const difyClient = new DifyClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await difyClient.getMeta("end-user"); await difyClient.getMeta("end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ const [url, config] = mockFetch.mock.calls[0];
method: routes.getMeta.method, expect(url).toContain(routes.getMeta.url());
url: routes.getMeta.url(), expect(url).toContain("user=end-user");
params: { user: "end-user" }, expect(config.method).toBe(routes.getMeta.method);
headers: expect.objectContaining({ expect(config.headers.Authorization).toBe("Bearer test");
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
}); });
}); });
@@ -97,49 +110,52 @@ describe("File uploads", () => {
it("does not override multipart boundary headers for FormData", async () => { it("does not override multipart boundary headers for FormData", async () => {
const difyClient = new DifyClient("test"); const difyClient = new DifyClient("test");
const form = new globalThis.FormData(); const form = new globalThis.FormData();
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await difyClient.fileUpload(form, "end-user"); await difyClient.fileUpload(form, "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ const [url, config] = mockFetch.mock.calls[0];
method: routes.fileUpload.method, expect(url).toContain(routes.fileUpload.url());
url: routes.fileUpload.url(), expect(config.method).toBe(routes.fileUpload.method);
params: undefined, expect(config.headers.Authorization).toBe("Bearer test");
headers: expect.objectContaining({ expect(config.headers["content-type"]).toBe("multipart/form-data; boundary=test");
Authorization: "Bearer test", expect(config.body).toBe(form);
"content-type": "multipart/form-data; boundary=test",
}),
responseType: "json",
timeout: 60000,
data: form,
}));
}); });
}); });
describe("Workflow client", () => { describe("Workflow client", () => {
it("uses tasks stop path for workflow stop", async () => { it("uses tasks stop path for workflow stop", async () => {
const workflowClient = new WorkflowClient("test"); const workflowClient = new WorkflowClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "stopped", headers: {} }); mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "stopped",
})
);
await workflowClient.stop("task-1", "end-user"); await workflowClient.stop("task-1", "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ const [url, config] = mockFetch.mock.calls[0];
method: routes.stopWorkflow.method, expect(url).toContain(routes.stopWorkflow.url("task-1"));
url: routes.stopWorkflow.url("task-1"), expect(config.method).toBe(routes.stopWorkflow.method);
params: undefined, expect(config.headers.Authorization).toBe("Bearer test");
headers: expect.objectContaining({ expect(config.headers["Content-Type"]).toBe("application/json");
Authorization: "Bearer test", expect(JSON.parse(config.body)).toEqual({ user: "end-user" });
"Content-Type": "application/json",
}),
responseType: "json",
timeout: 60000,
data: { user: "end-user" },
}));
}); });
it("maps workflow log filters to service api params", async () => { it("maps workflow log filters to service api params", async () => {
const workflowClient = new WorkflowClient("test"); const workflowClient = new WorkflowClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await workflowClient.getLogs({ await workflowClient.getLogs({
createdAtAfter: "2024-01-01T00:00:00Z", createdAtAfter: "2024-01-01T00:00:00Z",
@@ -150,78 +166,74 @@ describe("Workflow client", () => {
limit: 10, limit: 10,
}); });
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ const [url, config] = mockFetch.mock.calls[0];
method: "GET", expect(url).toContain("/workflows/logs");
url: "/workflows/logs", expect(url).toContain("created_at__after=2024-01-01T00%3A00%3A00Z");
params: { expect(url).toContain("created_at__before=2024-01-02T00%3A00%3A00Z");
created_at__after: "2024-01-01T00:00:00Z", expect(url).toContain("created_by_end_user_session_id=sess-1");
created_at__before: "2024-01-02T00:00:00Z", expect(url).toContain("created_by_account=acc-1");
created_by_end_user_session_id: "sess-1", expect(url).toContain("page=2");
created_by_account: "acc-1", expect(url).toContain("limit=10");
page: 2, expect(config.method).toBe("GET");
limit: 10, expect(config.headers.Authorization).toBe("Bearer test");
},
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
}); });
}); });
describe("Chat client", () => { describe("Chat client", () => {
it("places user in query for suggested messages", async () => { it("places user in query for suggested messages", async () => {
const chatClient = new ChatClient("test"); const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await chatClient.getSuggested("msg-1", "end-user"); await chatClient.getSuggested("msg-1", "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ const [url, config] = mockFetch.mock.calls[0];
method: routes.getSuggested.method, expect(url).toContain(routes.getSuggested.url("msg-1"));
url: routes.getSuggested.url("msg-1"), expect(url).toContain("user=end-user");
params: { user: "end-user" }, expect(config.method).toBe(routes.getSuggested.method);
headers: expect.objectContaining({ expect(config.headers.Authorization).toBe("Bearer test");
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
}); });
it("uses last_id when listing conversations", async () => { it("uses last_id when listing conversations", async () => {
const chatClient = new ChatClient("test"); const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await chatClient.getConversations("end-user", "last-1", 10); await chatClient.getConversations("end-user", "last-1", 10);
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ const [url, config] = mockFetch.mock.calls[0];
method: routes.getConversations.method, expect(url).toContain(routes.getConversations.url());
url: routes.getConversations.url(), expect(url).toContain("user=end-user");
params: { user: "end-user", last_id: "last-1", limit: 10 }, expect(url).toContain("last_id=last-1");
headers: expect.objectContaining({ expect(url).toContain("limit=10");
Authorization: "Bearer test", expect(config.method).toBe(routes.getConversations.method);
}), expect(config.headers.Authorization).toBe("Bearer test");
responseType: "json",
timeout: 60000,
}));
}); });
it("lists app feedbacks without user params", async () => { it("lists app feedbacks without user params", async () => {
const chatClient = new ChatClient("test"); const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await chatClient.getAppFeedbacks(1, 20); await chatClient.getAppFeedbacks(1, 20);
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ const [url, config] = mockFetch.mock.calls[0];
method: "GET", expect(url).toContain("/app/feedbacks");
url: "/app/feedbacks", expect(url).toContain("page=1");
params: { page: 1, limit: 20 }, expect(url).toContain("limit=20");
headers: expect.objectContaining({ expect(config.method).toBe("GET");
Authorization: "Bearer test", expect(config.headers.Authorization).toBe("Bearer test");
}),
responseType: "json",
timeout: 60000,
}));
}); });
}); });

View File

@@ -1,16 +1,15 @@
import axios from "axios";
import { vi } from "vitest"; import { vi } from "vitest";
import { HttpClient } from "../src/http/client"; import { HttpClient } from "../src/http/client";
export const createHttpClient = (configOverrides = {}) => { export const createHttpClient = (configOverrides = {}) => {
const mockRequest = vi.fn(); const mockFetch = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test", ...configOverrides }); const client = new HttpClient({ apiKey: "test", ...configOverrides });
return { client, mockRequest }; return { client, mockFetch };
}; };
export const createHttpClientWithSpies = (configOverrides = {}) => { export const createHttpClientWithSpies = (configOverrides = {}) => {
const { client, mockRequest } = createHttpClient(configOverrides); const { client, mockFetch } = createHttpClient(configOverrides);
const request = vi const request = vi
.spyOn(client, "request") .spyOn(client, "request")
.mockResolvedValue({ data: "ok", status: 200, headers: {} }); .mockResolvedValue({ data: "ok", status: 200, headers: {} });
@@ -22,7 +21,7 @@ export const createHttpClientWithSpies = (configOverrides = {}) => {
.mockResolvedValue({ data: null }); .mockResolvedValue({ data: null });
return { return {
client, client,
mockRequest, mockFetch,
request, request,
requestStream, requestStream,
requestBinaryStream, requestBinaryStream,