diff --git a/ui/src/Hooks/useFetchAny.js b/ui/src/Hooks/useFetchAny.js
new file mode 100644
index 000000000..26e3df806
--- /dev/null
+++ b/ui/src/Hooks/useFetchAny.js
@@ -0,0 +1,76 @@
+import { useState, useEffect } from "react";
+
+import merge from "lodash.merge";
+
+import { CommonOptions } from "Common/Fetch";
+
+const useFetchAny = (upstreams) => {
+ const [index, setIndex] = useState(0);
+ const [response, setResponse] = useState(null);
+ const [error, setError] = useState(null);
+ const [inProgress, setInProgress] = useState(true);
+ const [responseURI, setResponseURI] = useState(null);
+
+ useEffect(() => {
+ // https://dev.to/pallymore/clean-up-async-requests-in-useeffect-hooks-90h
+ let isCancelled = false;
+
+ const fetchData = async () => {
+ const { uri, options } = upstreams[index];
+
+ try {
+ setInProgress(true);
+ const res = await fetch(
+ uri,
+ merge({}, { method: "GET" }, CommonOptions, options)
+ );
+
+ if (!isCancelled) {
+ let body;
+ const contentType = res.headers.get("content-type");
+ if (contentType && contentType.indexOf("application/json") !== -1) {
+ body = await res.json();
+ } else {
+ body = await res.text();
+ }
+
+ if (res.ok) {
+ setResponse(body);
+ setResponseURI(uri);
+ setInProgress(false);
+ } else {
+ if (upstreams.length > index + 1) {
+ setIndex(index + 1);
+ } else {
+ setError(body);
+ setInProgress(false);
+ }
+ }
+ }
+ } catch (error) {
+ if (!isCancelled) {
+ if (upstreams.length > index + 1) {
+ setIndex(index + 1);
+ } else {
+ setError(error.message);
+ setInProgress(false);
+ }
+ }
+ }
+ };
+
+ if (upstreams.length > 0) {
+ fetchData();
+ } else {
+ setInProgress(false);
+ }
+
+ return () => {
+ isCancelled = true;
+ };
+ }, [upstreams, index]);
+
+ return { response, error, inProgress, responseURI };
+};
+
+export { useFetchAny };
diff --git a/ui/src/Hooks/useFetchAny.test.js b/ui/src/Hooks/useFetchAny.test.js
new file mode 100644
index 000000000..46925d40e
--- /dev/null
+++ b/ui/src/Hooks/useFetchAny.test.js
@@ -0,0 +1,326 @@
+import React from "react";
+
+import { renderHook } from "@testing-library/react-hooks";
+
+import { mount } from "enzyme";
+
+import fetchMock from "fetch-mock";
+
+import { useFetchAny } from "./useFetchAny";
+
+describe("useFetchAny", () => {
+ beforeAll(() => {
+ fetchMock.mock("http://localhost/ok", "body ok");
+ fetchMock.mock("http://localhost/ok/json", {
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ status: "ok" }),
+ });
+ fetchMock.mock("http://localhost/500", {
+ status: 500,
+ body: "fake error",
+ });
+ fetchMock.mock("http://localhost/error", {
+ throws: new TypeError("failed to fetch"),
+ });
+ });
+
+ beforeEach(() => {
+ fetchMock.resetHistory();
+ });
+
+ afterEach(() => {
+ fetchMock.resetHistory();
+ });
+
+ it("does nothing on empty upstream list", async () => {
+ const upstreams = [];
+ const { result } = renderHook(() => useFetchAny(upstreams));
+
+ expect(fetchMock.calls()).toHaveLength(0);
+ expect(result.current.inProgress).toBe(false);
+ });
+
+ it("sends a GET request by default", async () => {
+ const upstreams = [{ uri: "http://localhost/ok", options: {} }];
+ const { waitForNextUpdate } = renderHook(() => useFetchAny(upstreams));
+
+ await waitForNextUpdate();
+
+ expect(fetchMock.calls()).toHaveLength(1);
+ expect(fetchMock.lastCall()[0]).toBe("http://localhost/ok");
+ expect(fetchMock.lastCall()[1]).toMatchObject({
+ method: "GET",
+ credentials: "include",
+ mode: "cors",
+ redirect: "follow",
+ });
+ });
+
+ it("uses options from upstream", async () => {
+ const upstreams = [
+ {
+ uri: "http://localhost/ok",
+ options: { method: "POST", credentials: "same-site" },
+ },
+ ];
+ const { waitForNextUpdate } = renderHook(() => useFetchAny(upstreams));
+
+ await waitForNextUpdate();
+
+ expect(fetchMock.calls()).toHaveLength(1);
+ expect(fetchMock.lastCall()[0]).toBe("http://localhost/ok");
+ expect(fetchMock.lastCall()[1]).toMatchObject({
+ method: "POST",
+ credentials: "same-site",
+ mode: "cors",
+ redirect: "follow",
+ });
+ });
+
+ it("sends correct headers", async () => {
+ const upstreams = [{ uri: "http://localhost/ok", options: {} }];
+ const { waitForNextUpdate } = renderHook(() => useFetchAny(upstreams));
+
+ await waitForNextUpdate();
+
+ expect(fetchMock.calls()).toHaveLength(1);
+ expect(fetchMock.lastCall()[0]).toBe("http://localhost/ok");
+ expect(fetchMock.lastCall()[1]).toMatchObject({
+ mode: "cors",
+ credentials: "include",
+ redirect: "follow",
+ });
+ });
+
+ it("plain response is updated after successful fetch", async () => {
+ const upstreams = [{ uri: "http://localhost/ok", options: {} }];
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useFetchAny(upstreams)
+ );
+
+ expect(result.current.response).toBe(null);
+ expect(result.current.error).toBe(null);
+ expect(result.current.inProgress).toBe(true);
+ expect(result.current.responseURI).toBe(null);
+
+ await waitForNextUpdate();
+
+ expect(result.current.response).toBe("body ok");
+ expect(result.current.error).toBe(null);
+ expect(result.current.inProgress).toBe(false);
+ expect(result.current.responseURI).toBe("http://localhost/ok");
+ });
+
+ it("JSON response is updated after successful fetch", async () => {
+ const upstreams = [{ uri: "http://localhost/ok/json", options: {} }];
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useFetchAny(upstreams)
+ );
+
+ expect(result.current.response).toBe(null);
+ expect(result.current.error).toBe(null);
+ expect(result.current.inProgress).toBe(true);
+ expect(result.current.responseURI).toBe(null);
+
+ await waitForNextUpdate();
+
+ expect(result.current.response).toMatchObject({ status: "ok" });
+ expect(result.current.error).toBe(null);
+ expect(result.current.inProgress).toBe(false);
+ expect(result.current.responseURI).toBe("http://localhost/ok/json");
+ });
+
+ it("error is updated after 500 error", async () => {
+ const upstreams = [{ uri: "http://localhost/500", options: {} }];
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useFetchAny(upstreams)
+ );
+
+ expect(result.current.response).toBe(null);
+ expect(result.current.error).toBe(null);
+ expect(result.current.inProgress).toBe(true);
+ expect(result.current.responseURI).toBe(null);
+
+ await waitForNextUpdate();
+
+ expect(result.current.response).toBe(null);
+ expect(result.current.error).toBe("fake error");
+ expect(result.current.inProgress).toBe(false);
+ expect(result.current.responseURI).toBe(null);
+ });
+
+ it("error is updated after an exception", async () => {
+ const upstreams = [{ uri: "http://localhost/error", options: {} }];
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useFetchAny(upstreams)
+ );
+
+ expect(result.current.response).toBe(null);
+ expect(result.current.error).toBe(null);
+ expect(result.current.inProgress).toBe(true);
+ expect(result.current.responseURI).toBe(null);
+
+ await waitForNextUpdate();
+
+ expect(result.current.response).toBe(null);
+ expect(result.current.error).toBe("failed to fetch");
+ expect(result.current.inProgress).toBe(false);
+ expect(result.current.responseURI).toBe(null);
+ });
+
+ it("doesn't update response after cleanup", async () => {
+ fetchMock.mock(
+ "http://localhost/slow/ok",
+ new Promise((res) => setTimeout(() => res("ok"), 1000))
+ );
+
+ const upstreams = [{ uri: "http://localhost/slow/ok", options: {} }];
+ const Component = () => {
+ const { response, error, inProgress } = useFetchAny(upstreams);
+ return (
+
+ {response}
+ {error}
+ {inProgress}
+
+ );
+ };
+
+ const tree = mount();
+ tree.unmount();
+
+ await fetchMock.flush(true);
+ });
+
+ it("doesn't update error on 500 response after cleanup", async () => {
+ fetchMock.mock("http://localhost/slow/500", {
+ delay: 1000,
+ status: 500,
+ body: "error",
+ });
+
+ const upstreams = [{ uri: "http://localhost/slow/500", options: {} }];
+ const Component = () => {
+ const { response, error, inProgress } = useFetchAny(upstreams);
+ return (
+
+ {response}
+ {error}
+ {inProgress}
+
+ );
+ };
+
+ const tree = mount();
+ tree.unmount();
+
+ await fetchMock.flush(true);
+ });
+
+ it("doesn't update error on failed response after cleanup", async () => {
+ fetchMock.mock("http://localhost/slow/error", {
+ delay: 1000,
+ throws: new TypeError("failed to fetch"),
+ });
+
+ const upstreams = [{ uri: "http://localhost/slow/error", options: {} }];
+ const Component = () => {
+ const { response, error, inProgress } = useFetchAny(upstreams);
+ return (
+
+ {response}
+ {error}
+ {inProgress}
+
+ );
+ };
+
+ const tree = mount();
+ tree.unmount();
+
+ await fetchMock.flush(true);
+ });
+
+ it("doesn't retry on success", async () => {
+ const upstreams = [
+ { uri: "http://localhost/ok", options: {} },
+ { uri: "http://localhost/500", options: {} },
+ { uri: "http://localhost/error", options: {} },
+ ];
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useFetchAny(upstreams)
+ );
+
+ await waitForNextUpdate();
+
+ expect(fetchMock.calls()).toHaveLength(1);
+ expect(fetchMock.calls()[0][0]).toBe("http://localhost/ok");
+
+ expect(result.current.response).toBe("body ok");
+ expect(result.current.error).toBe(null);
+ expect(result.current.inProgress).toBe(false);
+ expect(result.current.responseURI).toBe("http://localhost/ok");
+ });
+
+ it("tries all URIs from the list on failures", async () => {
+ const upstreams = [
+ { uri: "http://localhost/500", options: {} },
+ { uri: "http://localhost/error", options: {} },
+ { uri: "http://localhost/ok", options: {} },
+ ];
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useFetchAny(upstreams)
+ );
+
+ await waitForNextUpdate();
+
+ expect(fetchMock.calls()).toHaveLength(3);
+ expect(fetchMock.calls()[0][0]).toBe("http://localhost/500");
+ expect(fetchMock.calls()[1][0]).toBe("http://localhost/error");
+ expect(fetchMock.calls()[2][0]).toBe("http://localhost/ok");
+
+ expect(result.current.response).toBe("body ok");
+ expect(result.current.error).toBe(null);
+ expect(result.current.inProgress).toBe(false);
+ expect(result.current.responseURI).toBe("http://localhost/ok");
+ });
+
+ it("first working URI sets the response", async () => {
+ const upstreams = [
+ { uri: "http://localhost/500", options: {} },
+ { uri: "http://localhost/ok/json", options: {} },
+ { uri: "http://localhost/error", options: {} },
+ ];
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useFetchAny(upstreams)
+ );
+
+ await waitForNextUpdate();
+
+ expect(fetchMock.calls()).toHaveLength(2);
+ expect(fetchMock.calls()[0][0]).toBe("http://localhost/500");
+ expect(fetchMock.calls()[1][0]).toBe("http://localhost/ok/json");
+
+ expect(result.current.response).toMatchObject({ status: "ok" });
+ expect(result.current.error).toBe(null);
+ expect(result.current.inProgress).toBe(false);
+ expect(result.current.responseURI).toBe("http://localhost/ok/json");
+ });
+
+ it("uses last error in the result", async () => {
+ const upstreams = [
+ { uri: "http://localhost/error", options: {} },
+ { uri: "http://localhost/500", options: {} },
+ ];
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useFetchAny(upstreams)
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current.response).toBe(null);
+ expect(result.current.error).toBe("fake error");
+ expect(result.current.inProgress).toBe(false);
+ expect(result.current.responseURI).toBe(null);
+ });
+});