fix(api): завершать запрос при обрыве ответа RouterOS

This commit is contained in:
Denozordec committed 2026-10-10 02:06:56 +07:00
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()))
}
})
+12
View File
@@ -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) })