Skip to content

Commit

Permalink
refactor(core): make fetcher can retry when request throws an error
Browse files Browse the repository at this point in the history
  • Loading branch information
async3619 committed Dec 5, 2022
1 parent 47d9be4 commit 91e1ec4
Show file tree
Hide file tree
Showing 5 changed files with 481 additions and 173 deletions.
5 changes: 5 additions & 0 deletions src/utils/buildQueryString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function buildQueryString(queries: Record<string, string | number>) {
return Object.entries(queries)
.map(([key, value]) => `${key}=${value}`)
.join("&");
}
265 changes: 265 additions & 0 deletions src/utils/fetcher.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import { Headers, HeadersInit, RequestInit } from "node-fetch";

import { Fetcher } from "@utils/fetcher";
import { throttle } from "@utils/throttle";

describe("Fetcher class", function () {
let target: Fetcher;

beforeEach(() => {
target = new Fetcher();

Object.defineProperty(target, "logger", {
value: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
silly: jest.fn(),
},
});
});

it("should provide a method to fetch data", async () => {
const res = await target.fetch({
url: "https://jsonplaceholder.typicode.com/todos/1",
});

expect(target.fetch).toBeDefined();
await expect(res.json()).resolves.toMatchObject({
userId: 1,
id: 1,
title: "delectus aut autem",
completed: false,
});
});

it("should provide a method to fetch json", async () => {
const res = await target.fetchJson({
url: "https://jsonplaceholder.typicode.com/todos/1",
});

expect(target.fetchJson).toBeDefined();
expect(res).toMatchObject({
userId: 1,
id: 1,
title: "delectus aut autem",
completed: false,
});
});

it("should add query params to the url if method is GET and data provided", async () => {
let calledUrl = "";
Object.defineProperty(target, "fetchImpl", {
value: jest.fn().mockImplementation((url: string) => {
calledUrl = url;

return Promise.resolve({
headers: {
get: () => "",
},
ok: true,
json: () => {
return Promise.resolve({ url });
},
});
}),
});

await target.fetch({
url: "https://jsonplaceholder.typicode.com/todos/1",
method: "GET",
data: {
test: "test",
},
});

expect(calledUrl).toBe("https://jsonplaceholder.typicode.com/todos/1?test=test");
});

it("should add body as json if method is not GET and data provided", async () => {
let calledBody: any = "";
let calledHeader: HeadersInit | undefined;
Object.defineProperty(target, "fetchImpl", {
value: jest.fn().mockImplementation((url: string, options: RequestInit) => {
calledBody = options.body;
calledHeader = options.headers;

return Promise.resolve({
headers: {
get: () => "",
},
ok: true,
json: () => {
return Promise.resolve({ url });
},
});
}),
});

await target.fetch({
url: "https://jsonplaceholder.typicode.com/todos/1",
method: "POST",
data: {
test: "test",
},
});

expect(calledHeader).toBeDefined();
if (calledHeader && calledHeader instanceof Headers) {
expect(calledHeader.get("Content-Type")).toBe("application/json");
}

expect(calledBody).toBe('{"test":"test"}');
});

it("should add cookies to the request if cookies are set", async () => {
let calledHeader: HeadersInit | undefined;
Object.defineProperty(target, "fetchImpl", {
value: jest.fn().mockImplementation((url: string, options: RequestInit) => {
calledHeader = options.headers;

return Promise.resolve({
headers: {
get: () => "",
},
ok: true,
json: () => {
return Promise.resolve({ url });
},
});
}),
});

target.hydrate({
cookies: {
test: "test",
},
});

await target.fetch({
url: "https://jsonplaceholder.typicode.com/todos/1",
method: "POST",
data: {
test: "test",
},
});

expect(calledHeader).toBeDefined();
if (calledHeader && calledHeader instanceof Headers) {
expect(calledHeader.get("cookie")).toBe("test=test");
}
});

it("should retry the request if it fails when retryCount is set", async () => {
let calledCount = 0;
Object.defineProperty(target, "fetchImpl", {
value: jest.fn().mockImplementation(() => {
calledCount++;

return Promise.resolve({
headers: { get: () => "" },
ok: false,
status: 500,
statusText: "Internal Server Error",
});
}),
});

await expect(target.fetch({ url: "", retryCount: 3, retryDelay: 0 })).rejects.toThrow(
"Failed to fetch : (500 Internal Server Error)",
);
expect(calledCount).toBe(4);
});

it("should retry with delay the request if it fails when retryCount & retryDealy is set", async () => {
let calledCount = 0;
Object.defineProperty(target, "fetchImpl", {
value: jest.fn().mockImplementation(() => {
calledCount++;

return Promise.resolve({
headers: { get: () => "" },
ok: false,
status: 500,
statusText: "Internal Server Error",
});
}),
});

const [, elapsedTime] = await throttle(
expect(target.fetch({ url: "", retryCount: 3, retryDelay: 500 })).rejects.toThrow(
"Failed to fetch : (500 Internal Server Error)",
),
0,
true,
);

expect(calledCount).toBe(4);
expect(elapsedTime).toBeGreaterThanOrEqual(1500);
});

it("should store cookies in the cookie jar if set-cookie header is present", async () => {
Object.defineProperty(target, "fetchImpl", {
value: jest.fn().mockImplementation(() => {
return Promise.resolve({
headers: {
get: (key: string) => {
if (key === "set-cookie") {
return "test=test";
}

return "";
},
},
ok: true,
json: () => {
return Promise.resolve({ url: "" });
},
});
}),
});

await target.fetch({
url: "https://jsonplaceholder.typicode.com/todos/1",
method: "POST",
data: {
test: "test",
},
});

expect(target.serialize().cookies).toMatchObject({ test: "test" });
});

it("should able to get the cookies from the cookie jar", async () => {
Object.defineProperty(target, "fetchImpl", {
value: jest.fn().mockImplementation(() => {
return Promise.resolve({
headers: {
get: (key: string) => {
if (key === "set-cookie") {
return "test=test";
}

return "";
},
},
ok: true,
json: () => {
return Promise.resolve({ url: "" });
},
});
}),
});

await target.fetch({
url: "https://jsonplaceholder.typicode.com/todos/1",
method: "POST",
data: {
test: "test",
},
});

expect(target.getCookies()).toMatchObject({ test: "test" });
});
});
74 changes: 65 additions & 9 deletions src/utils/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import nodeFetch, { Headers, RequestInit, Response } from "node-fetch";
import nodeFetch, { Headers, Response } from "node-fetch";

import { sleep } from "@utils/sleep";
import { Logger } from "@utils/logger";
import { parseCookie } from "@utils/parseCookie";
import { buildQueryString } from "@utils/buildQueryString";
import { Hydratable, Serializable } from "@utils/types";

interface FetchOption {
url: string;
method?: "GET" | "POST" | "PUT" | "DELETE";
retryCount?: number; // retry count (default: -1, infinite)
retryDelay?: number; // in ms (default: 1000)
data?: Record<string, any>;
headers?: Record<string, string>;
}

export class Fetcher implements Serializable, Hydratable {
private readonly cookies: Record<string, string> = {};
private readonly fetchImpl = nodeFetch;
private readonly logger = new Logger("Fetcher");

private getCookieString(): string {
return Object.entries(this.cookies)
Expand All @@ -28,20 +41,63 @@ export class Fetcher implements Serializable, Hydratable {
return { ...this.cookies };
}

public async fetchJson<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await this.fetch(url, options);
public async fetchJson<T>(options: FetchOption): Promise<T> {
const response = await this.fetch(options);
return response.json();
}
public async fetch(url: string, options: RequestInit = {}): Promise<Response> {
const headers = new Headers(options.headers);
headers.set("cookie", this.getCookieString());
public async fetch({
url,
headers,
data,
method = "GET",
retryCount = 0,
retryDelay = 1000,
}: FetchOption): Promise<Response> {
let endpoint = url;
if (method === "GET" && data) {
endpoint = `${endpoint}?${buildQueryString(data)}`;
}

const fetchHeaders = new Headers({
cookie: this.getCookieString(),
...headers,
});

if (method !== "GET" && data) {
fetchHeaders.set("Content-Type", "application/json");
}

const response = await this.fetchImpl(url, {
...options,
headers,
const response = await this.fetchImpl(endpoint, {
method: method,
headers: fetchHeaders,
body: method === "GET" ? undefined : JSON.stringify(data),
});

this.setCookies(response.headers.get("set-cookie"));

if (!response.ok) {
if (retryCount === 0) {
throw new Error(`Failed to fetch ${url}: (${response.status} ${response.statusText})`);
}

this.logger.error(`Failed to fetch ${url}: (${response.status} ${response.statusText})`);
if (retryDelay > 0) {
this.logger.error(`Retrying in ${retryDelay}ms ...`);
} else {
this.logger.error("Retrying ...");
}

await sleep(retryDelay);
return this.fetch({
url,
headers,
data,
method,
retryCount: retryCount - 1,
retryDelay,
});
}

return response;
}

Expand Down
Loading

0 comments on commit 91e1ec4

Please sign in to comment.