fix(api): завершать запрос при обрыве ответа RouterOS
This commit is contained in:
1 parent
5944e5e751
commit
7f0c2aad0f
13 files changed
+1005
-31
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { createServer } from "node:http"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
const methods = ["get", "post", "put", "patch", "delete", "file"] as const
|
||||
for (const method of methods) {
|
||||
test(`RouterOS ${method}: interrupted response rejects promptly without a successful receipt`, async () => {
|
||||
let requests = 0
|
||||
const server = createServer((req, res) => {
|
||||
req.resume()
|
||||
req.on("end", () => {
|
||||
requests++
|
||||
res.writeHead(200, { "content-type": "application/json", "content-length": "1000" })
|
||||
res.write('[{"status":')
|
||||
setTimeout(() => res.destroy(), 20)
|
||||
})
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address !== "string")
|
||||
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const operation = method === "file" ? client.downloadFile({ id: "*A", name: "", size: 8 }, 500)
|
||||
: method === "get" || method === "delete" ? client[method]("/fixture", 500)
|
||||
: client[method]("/fixture", {}, 500)
|
||||
await assert.rejects(Promise.race([operation, new Promise<never>((_, reject) => {
|
||||
deadline = setTimeout(() => reject(Error("Request did not settle after interrupted response")), 1200)
|
||||
})]), /ECONNRESET/)
|
||||
assert.equal(requests, method === "file" ? 2 : 1, "Writes must not retry; file reads keep two existing compatibility strategies")
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("RouterOS complete response: success, explicit HTTP rejection and body survive", async () => {
|
||||
const server = createServer((req, res) => {
|
||||
req.resume()
|
||||
req.on("end", () => {
|
||||
res.writeHead(req.url?.endsWith("/denied") ? 401 : 200, { "content-type": "application/json" })
|
||||
res.end(req.url?.endsWith("/denied") ? '{"message":"Unauthorized"}' : '{"ack":"confirmed"}')
|
||||
})
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address !== "string")
|
||||
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
|
||||
try {
|
||||
for (const method of ["get", "post", "put", "patch"] as const) {
|
||||
assert.deepEqual(await (method === "get" ? client.get("/fixture", 500) : client[method]("/fixture", {}, 500)), { ack: "confirmed" })
|
||||
await assert.rejects(method === "get" ? client.get("/denied", 500) : client[method]("/denied", {}, 500), /401.*Unauthorized/)
|
||||
}
|
||||
await client.delete("/fixture", 500)
|
||||
await assert.rejects(client.delete("/denied", 500), /401.*Unauthorized/)
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
}
|
||||
})
|
||||
@@ -15,6 +15,12 @@ import type {
|
||||
|
||||
const MAX_ROS_BODY_BYTES = 8 * 1024 * 1024
|
||||
|
||||
function watchRosResponseFailure(res: http.IncomingMessage, reject: (error: Error) => void): void {
|
||||
res.once("aborted", () => reject(new Error("RouterOS response interrupted (ECONNRESET)")))
|
||||
// IncomingMessage errors are separate from ClientRequest errors after headers arrive.
|
||||
res.once("error", reject)
|
||||
}
|
||||
|
||||
function appendRosBody(body: string, chunk: string, req?: http.ClientRequest): string {
|
||||
if (body.length + chunk.length > MAX_ROS_BODY_BYTES) {
|
||||
req?.destroy(new Error("RouterOS: ответ больше 8 МиБ"))
|
||||
@@ -62,6 +68,7 @@ function rosRequest(
|
||||
}, timeoutMs)
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
||||
let body = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { body = appendRosBody(body, chunk, req) })
|
||||
@@ -145,6 +152,7 @@ function rosPost(
|
||||
externalSignal?.addEventListener("abort", onExternalAbort)
|
||||
|
||||
req = lib.request(options, (res) => {
|
||||
watchRosResponseFailure(res, error => settle(() => reject(error)))
|
||||
let buf = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
|
||||
@@ -199,6 +207,7 @@ function rosPut(
|
||||
}, timeoutMs)
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
||||
let buf = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
|
||||
@@ -249,6 +258,7 @@ function rosDelete(
|
||||
}, timeoutMs)
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
||||
let body = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { body = appendRosBody(body, chunk, req) })
|
||||
@@ -296,6 +306,7 @@ function rosRawRequest(
|
||||
const lib = params.useSsl ? https : http
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
||||
const chunks: Buffer[] = []
|
||||
res.on("data", (chunk: Buffer) => { chunks.push(chunk) })
|
||||
res.on("end", () => {
|
||||
@@ -351,6 +362,7 @@ function rosPatch(
|
||||
}, timeoutMs)
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
||||
let buf = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
|
||||
|
||||
Reference in new issue
Block a user