unit/pool.test.js
/* eslint-env node, browser, mocha */
/* eslint-disable no-unused-expressions */
/* eslint-disable @typescript-eslint/no-var-requires */
import { expect } from "chai";
import * as http from "http";
import * as sinon from "sinon";
import * as https from "https";
import { ExponentialBackoff } from "../../src/backoff/exponential";
import { ConstantBackoff } from "../../src/backoff/constant";
import { Pool, RequestError, ServiceNotAvailableError } from "../../src/pool";
const hosts = 2;
describe("pool", () => {
let pool;
let clock;
let server;
let sid; // Random string to avoid conflicts with other running tests
const createPool = (backoff) => {
return new Pool({
backoff: backoff ||
new ExponentialBackoff({
initial: 300,
random: 0,
max: 10 * 1000,
}),
});
};
beforeEach((done) => {
pool = createPool();
sid = `${Date.now()}${Math.random()}`;
if (process.env.WEBPACK) {
for (let i = 0; i < hosts; i += 1) {
pool.addHost(location.origin);
}
done();
}
else {
const handler = require("../fixture/pool-middleware"); // eslint-disable-line @typescript-eslint/no-require-imports
server = http.createServer(handler());
server.listen(3005, () => {
for (let i = 0; i < hosts; i += 1) {
pool.addHost(`http://127.0.0.1:${3005}`);
}
done();
});
}
});
afterEach((done) => {
if (clock) {
clock.restore();
}
if (process.env.WEBPACK) {
done();
}
else {
server.close(() => done());
}
});
it("attempts to make an https request", () => {
const p = createPool();
p.addHost("https://httpbin.org");
return p.json({ method: "GET", path: "/get" });
});
it("passes through request options", () => {
const spy = sinon.spy(https, "request");
const p = createPool();
p.addHost("https://httpbin.org", { rejectUnauthorized: false });
return p.json({ method: "GET", path: "/get" }).then(() => {
expect(spy.args[0][0].rejectUnauthorized).to.be.false;
});
});
it("valid request data content length", () => {
const p = createPool();
const body = "\u00FF";
p.addHost("https://httpbin.org");
p.json({ method: "POST", path: "/post", body: body }).then((data) => expect(data.data).to.equal(body));
});
it("handles unicode chunks correctly", () => {
const p = createPool();
const body = "درود".repeat(40960);
p.addHost("https://httpbin.org");
p.json({ method: "POST", path: "/post", body: body }).then((data) => expect(data.data).to.equal(body));
});
describe("request generators", () => {
it("makes a text request", () => {
return pool
.text({ method: "GET", path: "/pool/json" })
.then((data) => expect(data).to.equal('{"ok":true}'));
});
it("includes request query strings and bodies", () => {
return pool
.json({
method: "POST",
path: "/pool/echo",
query: { a: 42 },
body: "asdf",
})
.then((data) => {
expect(data).to.deep.equal({
query: "a=42",
body: "asdf",
method: "POST",
});
});
});
it("discards responses", () => {
return pool.discard({ method: "GET", path: "/pool/204" });
});
it("parses JSON responses", () => {
return pool
.json({ method: "GET", path: "/pool/json" })
.then((data) => expect(data).to.deep.equal({ ok: true }));
});
it("errors if JSON parsing fails", () => {
return pool
.json({ method: "GET", path: "/pool/badjson" })
.then(() => {
throw new Error("Expected to have thrown");
})
.catch((err) => expect(err).to.be.an.instanceof(SyntaxError));
});
});
it("times out requests", () => {
pool._timeout = 1;
return pool
.text({ method: "GET", path: "/pool/wait-json" })
.then(() => {
throw new Error("Expected to have thrown");
})
.catch((err) => expect(err).be.an.instanceof(ServiceNotAvailableError))
.then(() => {
pool._timeout = 10000;
});
});
it("retries on a request error", () => {
return pool
.text({ method: "GET", path: `/pool/altFail-${sid}/json` })
.then((body) => expect(body).to.equal('{"ok":true}'));
});
it("fails if too many errors happen", () => {
expect(pool.hostIsAvailable()).to.be.true;
return pool
.discard({ method: "GET", path: "/pool/502" })
.then(() => {
throw new Error("Expected to have thrown");
})
.catch((err) => {
expect(err).to.be.an.instanceof(ServiceNotAvailableError);
expect(pool.hostIsAvailable()).to.be.false;
});
});
it("calls back immediately on un-retryable error", () => {
return pool
.discard({ method: "GET", path: "/pool/400" })
.then(() => {
throw new Error("Expected to have thrown");
})
.catch((err) => {
expect(err).to.be.an.instanceof(RequestError);
expect(err.res.statusCode).to.equal(400);
expect(pool.hostIsAvailable()).to.be.true;
});
});
it("pings servers", () => {
return pool.ping(1000, `/pool/altFail-${sid}/ping`).then((results) => {
if (results[0].online) {
[results[0], results[1]] = [results[1], results[0]];
}
expect(results[0].online).to.be.false;
expect(results[1].online).to.be.true;
expect(results[1].version).to.equal("v1.0.0");
});
});
it("times out in pings", () => {
return pool.ping(1).then((results) => {
expect(results[0].online).to.be.false;
expect(results[1].online).to.be.false;
});
});
describe("backoff", () => {
describe("exponential", () => {
beforeEach(() => {
clock = sinon.useFakeTimers();
return pool.discard({ method: "GET", path: "/pool/502" }).catch(() => {
/* ignore */
});
});
it("should error if there are no available hosts", () => {
return pool
.discard({ method: "GET", path: "/pool/json" })
.then(() => {
throw new Error("Expected to have thrown");
})
.catch((err) => {
expect(err).to.be.an.instanceof(ServiceNotAvailableError);
expect(err.message).to.equal("No host available");
});
});
it("should reenable hosts after the backoff expires", () => {
expect(pool.hostIsAvailable()).to.be.false;
clock.tick(300);
expect(pool.hostIsAvailable()).to.be.true;
});
it("should back off if failures continue", () => {
clock.tick(300);
expect(pool.hostIsAvailable()).to.be.true;
return pool
.discard({ method: "GET", path: "/pool/502" })
.then(() => {
throw new Error("Expected to have thrown");
})
.catch((err) => {
expect(err).to.be.an.instanceof(ServiceNotAvailableError);
expect(pool.hostIsAvailable()).to.be.false;
clock.tick(300);
expect(pool.hostIsAvailable()).to.be.false;
clock.tick(300);
expect(pool.hostIsAvailable()).to.be.true;
});
});
it("should reset backoff after success", () => {
clock.tick(300);
expect(pool.hostIsAvailable()).to.be.true;
return pool
.discard({ method: "GET", path: "/pool/204" })
.then(() => {
return pool.discard({ method: "GET", path: "/pool/502" });
})
.then(() => {
throw new Error("Expected to have thrown");
})
.catch((err) => {
expect(err).not.to.be.undefined;
expect(pool.hostIsAvailable()).to.be.false;
clock.tick(300);
expect(pool.hostIsAvailable()).to.be.true;
});
});
});
describe("constant", () => {
const createConstantBackoffPool = (options) => {
const p = createPool(new ConstantBackoff(options));
for (let i = 0; i < hosts; i += 1) {
p.addHost(process.env.WEBPACK ? location.origin : `http://127.0.0.1:${3005}`);
}
return p;
};
it("should disable hosts if backoff delay is greater than zero", () => {
const p = createConstantBackoffPool({ delay: 300, jitter: 0 });
return p
.discard({ method: "GET", path: "/pool/502" })
.then(() => {
throw new Error("Expected to have thrown");
})
.catch((err) => {
expect(err).to.be.an.instanceof(ServiceNotAvailableError);
expect(err.message).to.equal("Bad Gateway");
expect(p.getHostsDisabled().length).to.be.greaterThan(0);
});
});
it("should not disable hosts if backoff delay is zero", () => {
const p = createConstantBackoffPool({ delay: 0, jitter: 0 });
return p
.discard({ method: "GET", path: "/pool/502" })
.then(() => {
throw new Error("Expected to have thrown");
})
.catch((err) => {
expect(err).to.be.an.instanceof(ServiceNotAvailableError);
expect(err.message).to.equal("Bad Gateway");
expect(p.getHostsDisabled().length).to.be.equal(0);
});
});
});
});
});