Skip to content

Commit

Permalink
Merge pull request #220 from NYPL/DR-3251/feature/handle-504-timeout
Browse files Browse the repository at this point in the history
DR-3251: Refactor API handler, fix timeout error
  • Loading branch information
7emansell authored Nov 7, 2024
2 parents 727e30a + 9abd60c commit e24aa48
Show file tree
Hide file tree
Showing 6 changed files with 252 additions and 202 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Updated

- Updated cards to use Next Image (DR-3056)
- Refactored Repo API handler with added timeout errors (DR-3251)
- Removed canonical tag to old DC (DR-3264)

## [0.1.16] 2024-10-31
Expand Down
2 changes: 1 addition & 1 deletion app/src/data/featuredContentData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const featuredContentData: FeaturedContentDataType[] = [
{
heading: "Spotlight on the public domain",
overline: "Featured",
text: "The New York Public Library recently enhanced access to all public domain items in Digital Collections so that everyone has the freedom to enjoy and reuse these materials in almost limitless ways",
text: "The New York Public Library recently enhanced access to all public domain items in Digital Collections so that everyone has the freedom to enjoy and reuse these materials in almost limitless ways.",
link: "https://publicdomain.nypl.org",
buttonText: "Learn more",
buttonId: "featured-learn-more",
Expand Down
179 changes: 179 additions & 0 deletions app/src/utils/api.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { waitFor } from "@testing-library/react";
import { apiResponse, getItemsCountFromUUIDs } from "./api";

describe("getItemsCountFromUUIDs()", () => {
it("should throw an error with a bad request", async () => {
(global as any).fetch = jest.fn(() =>
Promise.resolve({
status: 403,
json: () => Promise.resolve({ nyplAPI: { response: "success" } }),
})
) as jest.Mock;

const data = ["uuid1", "uuid2"];
await expect(getItemsCountFromUUIDs(data)).rejects.toEqual(
new Error("apiResponse: 403 No message")
);
});

it("should return an empty object with a successful request but bad data structure", async () => {
(global as any).fetch = jest.fn(() =>
Promise.resolve({
status: 200,
json: () => Promise.resolve({ nyplAPI: { response: { counts: {} } } }),
})
) as jest.Mock;

const data = ["uuid1", "uuid2"];

await waitFor(async () => {
expect(await getItemsCountFromUUIDs(data)).toEqual({});
});
});

it("should return a map of all the uuids passed with their item count", async () => {
(global as any).fetch = jest.fn(() =>
Promise.resolve({
status: 200,
json: () =>
Promise.resolve({
nyplAPI: {
response: {
counts: {
count: [
{ uuid: { $: "uuid1" }, count_value: { $: "10" } },
{ uuid: { $: "uuid2" }, count_value: { $: "45" } },
],
},
},
},
}),
})
) as jest.Mock;

const data = ["uuid1", "uuid2"];

await waitFor(async () => {
expect(await getItemsCountFromUUIDs(data)).toEqual({
uuid1: "10",
uuid2: "45",
});
});
});
});

describe("apiResponse", () => {
const mockApiUrl = "mockurl.org";
const mockAuthToken = "mockAuthToken";

beforeAll(() => {
process.env.AUTH_TOKEN = mockAuthToken;
});

it("makes a GET request and returns response", async () => {
const mockResponse = { nyplAPI: { response: "mockResponse" } };
(global as any).fetch = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: async () => mockResponse,
})
) as jest.Mock;

const response = await apiResponse(mockApiUrl);
expect(fetch).toHaveBeenCalledWith(mockApiUrl, {
method: "GET",
headers: {
Authorization: `Token token=${mockAuthToken}`,
},
body: undefined,
});
expect(response).toEqual("mockResponse");
});

it("makes a POST request with a body and returns response", async () => {
const mockBody = { key: "value" };
const mockResponse = { result: "mockPostResponse" };
(global as any).fetch = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: async () => mockResponse,
})
) as jest.Mock;

const response = await apiResponse(mockApiUrl, {
method: "POST",
body: mockBody,
});

expect(fetch).toHaveBeenCalledWith(mockApiUrl, {
method: "POST",
headers: {
Authorization: `Token token=${mockAuthToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(mockBody),
});
expect(response).toEqual(mockResponse);
});

it("adds query parameters for GET requests", async () => {
const mockParams = { param1: "value1", param2: "value2" };
const mockResponse = { nyplAPI: { response: "mockGetResponseWithParams" } };
(global as any).fetch = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: async () => mockResponse,
})
) as jest.Mock;

const response = await apiResponse(mockApiUrl, {
params: mockParams,
});

expect(fetch).toHaveBeenCalledWith(
`${mockApiUrl}?param1=value1&param2=value2`,
{
method: "GET",
headers: {
Authorization: `Token token=${mockAuthToken}`,
},
body: undefined,
}
);
expect(response).toEqual("mockGetResponseWithParams");
});

it("throws an error for non-200 status", async () => {
(global as any).fetch = jest.fn(() =>
Promise.resolve({
status: 500,
statusText: "Internal Server Error",
})
) as jest.Mock;

await expect(apiResponse(mockApiUrl)).rejects.toEqual(
new Error("apiResponse: 500 Internal Server Error")
);
});

it("throws a timeout error if the request takes too long", async () => {
jest.useFakeTimers();

(global as any).fetch = jest
.fn()
.mockImplementation(() => new Promise(() => {}));

const apiCall = apiResponse(mockApiUrl);

jest.advanceTimersByTime(7000);

await expect(apiCall).rejects.toEqual(
new Error("apiResponse: Request timed out")
);

jest.useRealTimers();
}, 10000);
});
157 changes: 70 additions & 87 deletions app/src/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const getFeaturedImage = async () => {

export const getItemData = async (uuid: string) => {
const apiUrl = `${process.env.API_URL}/api/v2/items/mods_captures/${uuid}`;
const res = await RepoAPICall(apiUrl);
const res = await apiResponse(apiUrl);
return res;
};

Expand All @@ -106,12 +106,15 @@ export const getNumDigitizedItems = async () => {
*/
export const getItemsCountFromUUIDs = async (uuids: string[]) => {
const apiUrl = `${process.env.API_URL}/api/v2/items/counts`;
const response = await apiPOSTCall(apiUrl, { uuids });
const response = await apiResponse(apiUrl, {
method: "POST",
body: { uuids },
});

const { counts } = response.nyplAPI.response;
if (!counts?.count?.length) {
return {};
}

// The response is an array of objects:
// [
// { uuid: { $: 'uuid1' }, count_value: { $: 'count1' }}
Expand Down Expand Up @@ -140,111 +143,91 @@ export const getItemByIdentifier = async (
urlParam?: { [key: string]: any }
) => {
const apiUrl = `${process.env.API_URL}/api/v2/items/${identifierType}/${identifier}`;
const apiCallValue = apiResponse(apiUrl, urlParam);
return apiCallValue;
return apiResponse(apiUrl, { params: urlParam });
};

/**
* Returns Repo API response.
* @param {string} apiUrl - the url to make a request to
*/
export const getDivisionData = async ({
pageNum = 1,
perPage = CARDS_PER_PAGE,
slug,
}: {
pageNum?: number;
perPage?: number;
slug?: string;
} = {}) => {
let apiUrl = `${process.env.API_URL}/api/v2/divisions`;

export const apiResponse = async (
apiUrl: string,
urlParam?: { [key: string]: any }
) => {
const data = await RepoAPICall(apiUrl, urlParam);
return data?.nyplAPI?.response;
if (slug) {
apiUrl += `/${slug}?page=${pageNum}&per_page=${perPage}`;
}

const res = await apiResponse(apiUrl);
return res;
};

/**
* Returns Repo API response WITH request data.
* @param {string} apiUrl - the url to make a request to
* @param {[key: string]} urlParam = url parameters to use in the request
* Makes a GET or POST request to the Repo API and returns the response.
* Times out at 7 seconds to prevent 504 crash.
* @param {string} apiUrl - The URL for the API request.
* @param {object} options - Options for the request:
* - method: "GET" or "POST" (default is "GET").
* - params: URL parameters for GET requests.
* - body: Body data for POST requests.
* @returns {Promise<any>} - The API response.
*/

export const RepoAPICall = async (
export const apiResponse = async (
apiUrl: string,
urlParam?: { [key: string]: any }
options?: {
method?: "GET" | "POST";
params?: { [key: string]: any };
body?: any;
}
) => {
const apiKey = process.env.AUTH_TOKEN;
const queryString = urlParam
? "?" + new URLSearchParams(urlParam).toString()
: "";
apiUrl += queryString;

try {
const response = await fetch(apiUrl, {
// aggressively cache Repo API?
// cache: "force-cache",
headers: {
Authorization: `Token token=${apiKey}`,
},
});
const method = options?.method || "GET";
const headers = {
Authorization: `Token token=${apiKey}`,
...(method === "POST" && { "Content-Type": "application/json" }),
};

if (response.status === 200) {
const data = await response.json();
return data;
} else {
throw new Error(
`RepoAPICall: ${response.status}: ${
response.statusText || "3xx/4xx error"
}`
);
}
} catch (error) {
console.error(error);
throw new Error(`RepoAPICall: ${error.message}`);
if (method === "GET" && options?.params) {
const queryString = "?" + new URLSearchParams(options?.params).toString();
apiUrl += queryString;
}
};

/**
* Makes a POST request to Repo API.
*/
export const apiPOSTCall = async (apiUrl: string, postData: any) => {
const apiKey = process.env.AUTH_TOKEN;
const timeout = 7000;

const fetchWithTimeout = (url: string, opts: RequestInit) => {
return Promise.race([
fetch(url, opts),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("apiResponse: Request timed out")),
timeout
)
),
]);
};

try {
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Token token=${apiKey}`,
},
body: JSON.stringify(postData),
});
const response = (await fetchWithTimeout(apiUrl, {
method,
headers,
body: method === "POST" ? JSON.stringify(options?.body) : undefined,
})) as Response;

if (response.status === 200) {
const data = await response.json();
return data;
} else {
if (!response.ok && response.status !== 200) {
throw new Error(
`apiPOSTCall: ${response.status}: ${
response.statusText || "3xx/4xx error"
`apiResponse: ${response.status} ${
response.statusText ? response.statusText : "No message"
}`
);
}

const data = await response.json();
return method === "GET" ? data?.nyplAPI?.response : data;
} catch (error) {
console.error(error);
throw new Error(`apiPOSTCall: ${error.message}`);
throw new Error(error.message);
}
};

export const getDivisionData = async ({
pageNum = 1,
perPage = CARDS_PER_PAGE,
slug,
}: {
pageNum?: number;
perPage?: number;
slug?: string;
} = {}) => {
let apiUrl = `${process.env.API_URL}/api/v2/divisions`;

if (slug) {
apiUrl += `/${slug}?page=${pageNum}&per_page=${perPage}`;
}

const res = await apiResponse(apiUrl);
return res;
};
Loading

0 comments on commit e24aa48

Please sign in to comment.