mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-08 06:25:45 +01:00
Do not fail on error 502 during restart actions (#1941)
In some rare cases a reverse proxy stops forwarding traffic to scm, before the response is returned to scm. In such a case the reverse proxy returns 502 (bad gateway), so we treat 502 not as error for restart actions. Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
2
gradle/changelog/restart_bad_gateway.yaml
Normal file
2
gradle/changelog/restart_bad_gateway.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- type: fixed
|
||||||
|
description: Do not fail on 502 during restart actions ([#1941](https://github.com/scm-manager/scm-manager/pull/1941))
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
import { apiClient, createUrl, extractXsrfTokenFromCookie } from "./apiclient";
|
import { apiClient, createUrl, extractXsrfTokenFromCookie } from "./apiclient";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
import { BackendError } from "./errors";
|
import { BackendError, BadGatewayError } from "./errors";
|
||||||
|
|
||||||
describe("create url", () => {
|
describe("create url", () => {
|
||||||
it("should not change absolute urls", () => {
|
it("should not change absolute urls", () => {
|
||||||
@@ -45,9 +45,9 @@ describe("error handling tests", () => {
|
|||||||
context: [
|
context: [
|
||||||
{
|
{
|
||||||
type: "planet",
|
type: "planet",
|
||||||
id: "earth",
|
id: "earth"
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -55,9 +55,9 @@ describe("error handling tests", () => {
|
|||||||
fetchMock.restore();
|
fetchMock.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create a normal error, if the content type is not scmm-error", (done) => {
|
it("should create a normal error, if the content type is not scmm-error", done => {
|
||||||
fetchMock.getOnce("/api/v2/error", {
|
fetchMock.getOnce("/api/v2/error", {
|
||||||
status: 404,
|
status: 404
|
||||||
});
|
});
|
||||||
|
|
||||||
apiClient.get("/error").catch((err: Error) => {
|
apiClient.get("/error").catch((err: Error) => {
|
||||||
@@ -67,13 +67,24 @@ describe("error handling tests", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create an backend error, if the content type is scmm-error", (done) => {
|
it("should create a bad gateway error", done => {
|
||||||
|
fetchMock.getOnce("/api/v2/error", {
|
||||||
|
status: 502
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.get("/error").catch((err: Error) => {
|
||||||
|
expect(err).toBeInstanceOf(BadGatewayError);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create an backend error, if the content type is scmm-error", done => {
|
||||||
fetchMock.getOnce("/api/v2/error", {
|
fetchMock.getOnce("/api/v2/error", {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/vnd.scmm-error+json;v=2",
|
"Content-Type": "application/vnd.scmm-error+json;v=2"
|
||||||
},
|
},
|
||||||
body: earthNotFoundError,
|
body: earthNotFoundError
|
||||||
});
|
});
|
||||||
|
|
||||||
apiClient.get("/error").catch((err: BackendError) => {
|
apiClient.get("/error").catch((err: BackendError) => {
|
||||||
@@ -87,8 +98,8 @@ describe("error handling tests", () => {
|
|||||||
expect(err.context).toEqual([
|
expect(err.context).toEqual([
|
||||||
{
|
{
|
||||||
type: "planet",
|
type: "planet",
|
||||||
id: "earth",
|
id: "earth"
|
||||||
},
|
}
|
||||||
]);
|
]);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,12 +25,13 @@
|
|||||||
import { contextPath } from "./urls";
|
import { contextPath } from "./urls";
|
||||||
import {
|
import {
|
||||||
BackendErrorContent,
|
BackendErrorContent,
|
||||||
|
BadGatewayError,
|
||||||
createBackendError,
|
createBackendError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
isBackendError,
|
isBackendError,
|
||||||
TOKEN_EXPIRED_ERROR_CODE,
|
TOKEN_EXPIRED_ERROR_CODE,
|
||||||
TokenExpiredError,
|
TokenExpiredError,
|
||||||
UnauthorizedError,
|
UnauthorizedError
|
||||||
} from "./errors";
|
} from "./errors";
|
||||||
|
|
||||||
type SubscriptionEvent = {
|
type SubscriptionEvent = {
|
||||||
@@ -62,7 +63,12 @@ type SubscriptionArgument = MessageListeners | SubscriptionContext;
|
|||||||
|
|
||||||
type Cancel = () => void;
|
type Cancel = () => void;
|
||||||
|
|
||||||
const sessionId = (Date.now().toString(36) + Math.random().toString(36).substr(2, 5)).toUpperCase();
|
const sessionId = (
|
||||||
|
Date.now().toString(36) +
|
||||||
|
Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.substr(2, 5)
|
||||||
|
).toUpperCase();
|
||||||
|
|
||||||
const extractXsrfTokenFromJwt = (jwt: string) => {
|
const extractXsrfTokenFromJwt = (jwt: string) => {
|
||||||
const parts = jwt.split(".");
|
const parts = jwt.split(".");
|
||||||
@@ -97,7 +103,7 @@ const createRequestHeaders = () => {
|
|||||||
// identify the web interface
|
// identify the web interface
|
||||||
"X-SCM-Client": "WUI",
|
"X-SCM-Client": "WUI",
|
||||||
// identify the window session
|
// identify the window session
|
||||||
"X-SCM-Session-ID": sessionId,
|
"X-SCM-Session-ID": sessionId
|
||||||
};
|
};
|
||||||
|
|
||||||
const xsrf = extractXsrfToken();
|
const xsrf = extractXsrfToken();
|
||||||
@@ -107,10 +113,10 @@ const createRequestHeaders = () => {
|
|||||||
return headers;
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyFetchOptions: (p: RequestInit) => RequestInit = (o) => {
|
const applyFetchOptions: (p: RequestInit) => RequestInit = o => {
|
||||||
if (o.headers) {
|
if (o.headers) {
|
||||||
o.headers = {
|
o.headers = {
|
||||||
...createRequestHeaders(),
|
...createRequestHeaders()
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
o.headers = createRequestHeaders();
|
o.headers = createRequestHeaders();
|
||||||
@@ -134,6 +140,8 @@ function handleFailure(response: Response) {
|
|||||||
throw new UnauthorizedError("Unauthorized", 401);
|
throw new UnauthorizedError("Unauthorized", 401);
|
||||||
} else if (response.status === 403) {
|
} else if (response.status === 403) {
|
||||||
throw new ForbiddenError("Forbidden", 403);
|
throw new ForbiddenError("Forbidden", 403);
|
||||||
|
} else if (response.status === 502) {
|
||||||
|
throw new BadGatewayError("Bad Gateway", 502);
|
||||||
} else if (isBackendError(response)) {
|
} else if (isBackendError(response)) {
|
||||||
return response.json().then((content: BackendErrorContent) => {
|
return response.json().then((content: BackendErrorContent) => {
|
||||||
throw createBackendError(content, response.status);
|
throw createBackendError(content, response.status);
|
||||||
@@ -169,7 +177,9 @@ class ApiClient {
|
|||||||
requestListeners: RequestListener[] = [];
|
requestListeners: RequestListener[] = [];
|
||||||
|
|
||||||
get = (url: string): Promise<Response> => {
|
get = (url: string): Promise<Response> => {
|
||||||
return this.request(url, applyFetchOptions({})).then(handleFailure).catch(this.notifyAndRethrow);
|
return this.request(url, applyFetchOptions({}))
|
||||||
|
.then(handleFailure)
|
||||||
|
.catch(this.notifyAndRethrow);
|
||||||
};
|
};
|
||||||
|
|
||||||
post = (
|
post = (
|
||||||
@@ -196,7 +206,7 @@ class ApiClient {
|
|||||||
const options: RequestInit = {
|
const options: RequestInit = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: additionalHeaders,
|
headers: additionalHeaders
|
||||||
};
|
};
|
||||||
return this.httpRequestWithBinaryBody(options, url);
|
return this.httpRequestWithBinaryBody(options, url);
|
||||||
};
|
};
|
||||||
@@ -207,18 +217,22 @@ class ApiClient {
|
|||||||
|
|
||||||
head = (url: string) => {
|
head = (url: string) => {
|
||||||
let options: RequestInit = {
|
let options: RequestInit = {
|
||||||
method: "HEAD",
|
method: "HEAD"
|
||||||
};
|
};
|
||||||
options = applyFetchOptions(options);
|
options = applyFetchOptions(options);
|
||||||
return this.request(url, options).then(handleFailure).catch(this.notifyAndRethrow);
|
return this.request(url, options)
|
||||||
|
.then(handleFailure)
|
||||||
|
.catch(this.notifyAndRethrow);
|
||||||
};
|
};
|
||||||
|
|
||||||
delete = (url: string): Promise<Response> => {
|
delete = (url: string): Promise<Response> => {
|
||||||
let options: RequestInit = {
|
let options: RequestInit = {
|
||||||
method: "DELETE",
|
method: "DELETE"
|
||||||
};
|
};
|
||||||
options = applyFetchOptions(options);
|
options = applyFetchOptions(options);
|
||||||
return this.request(url, options).then(handleFailure).catch(this.notifyAndRethrow);
|
return this.request(url, options)
|
||||||
|
.then(handleFailure)
|
||||||
|
.catch(this.notifyAndRethrow);
|
||||||
};
|
};
|
||||||
|
|
||||||
httpRequestWithJSONBody = (
|
httpRequestWithJSONBody = (
|
||||||
@@ -230,7 +244,7 @@ class ApiClient {
|
|||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const options: RequestInit = {
|
const options: RequestInit = {
|
||||||
method: method,
|
method: method,
|
||||||
headers: additionalHeaders,
|
headers: additionalHeaders
|
||||||
};
|
};
|
||||||
if (payload) {
|
if (payload) {
|
||||||
options.body = JSON.stringify(payload);
|
options.body = JSON.stringify(payload);
|
||||||
@@ -246,7 +260,7 @@ class ApiClient {
|
|||||||
) => {
|
) => {
|
||||||
const options: RequestInit = {
|
const options: RequestInit = {
|
||||||
method: method,
|
method: method,
|
||||||
headers: additionalHeaders,
|
headers: additionalHeaders
|
||||||
};
|
};
|
||||||
options.body = payload;
|
options.body = payload;
|
||||||
return this.httpRequestWithBinaryBody(options, url, "text/plain");
|
return this.httpRequestWithBinaryBody(options, url, "text/plain");
|
||||||
@@ -262,12 +276,14 @@ class ApiClient {
|
|||||||
options.headers["Content-Type"] = contentType;
|
options.headers["Content-Type"] = contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.request(url, options).then(handleFailure).catch(this.notifyAndRethrow);
|
return this.request(url, options)
|
||||||
|
.then(handleFailure)
|
||||||
|
.catch(this.notifyAndRethrow);
|
||||||
};
|
};
|
||||||
|
|
||||||
subscribe(url: string, argument: SubscriptionArgument): Cancel {
|
subscribe(url: string, argument: SubscriptionArgument): Cancel {
|
||||||
const es = new EventSource(createUrlWithIdentifiers(url), {
|
const es = new EventSource(createUrlWithIdentifiers(url), {
|
||||||
withCredentials: true,
|
withCredentials: true
|
||||||
});
|
});
|
||||||
|
|
||||||
let listeners: MessageListeners;
|
let listeners: MessageListeners;
|
||||||
@@ -308,11 +324,11 @@ class ApiClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private notifyRequestListeners = (url: string, options: RequestInit) => {
|
private notifyRequestListeners = (url: string, options: RequestInit) => {
|
||||||
this.requestListeners.forEach((requestListener) => requestListener(url, options));
|
this.requestListeners.forEach(requestListener => requestListener(url, options));
|
||||||
};
|
};
|
||||||
|
|
||||||
private notifyAndRethrow = (error: Error): never => {
|
private notifyAndRethrow = (error: Error): never => {
|
||||||
this.errorListeners.forEach((errorListener) => errorListener(error));
|
this.errorListeners.forEach(errorListener => errorListener(error));
|
||||||
throw error;
|
throw error;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ export class UnauthorizedError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BadGatewayError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
constructor(message: string, statusCode: number) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class TokenExpiredError extends UnauthorizedError {}
|
export class TokenExpiredError extends UnauthorizedError {}
|
||||||
|
|
||||||
export class ForbiddenError extends Error {
|
export class ForbiddenError extends Error {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { isPluginCollection, PendingPlugins, Plugin, PluginCollection } from "@s
|
|||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
import { apiClient } from "./apiclient";
|
import { apiClient } from "./apiclient";
|
||||||
import { requiredLink } from "./links";
|
import { requiredLink } from "./links";
|
||||||
|
import { BadGatewayError } from "./errors";
|
||||||
|
|
||||||
type WaitForRestartOptions = {
|
type WaitForRestartOptions = {
|
||||||
initialDelay?: number;
|
initialDelay?: number;
|
||||||
@@ -37,12 +38,10 @@ const waitForRestartAfter = (
|
|||||||
promise: Promise<any>,
|
promise: Promise<any>,
|
||||||
{ initialDelay = 1000, timeout = 500 }: WaitForRestartOptions = {}
|
{ initialDelay = 1000, timeout = 500 }: WaitForRestartOptions = {}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const endTime = Number(new Date()) + 60000;
|
const endTime = Number(new Date()) + 4 * 60 * 1000;
|
||||||
let started = false;
|
let started = false;
|
||||||
|
|
||||||
const executor =
|
const executor = <T = any>(data: T) => (resolve: (result: T) => void, reject: (error: Error) => void) => {
|
||||||
<T = any>(data: T) =>
|
|
||||||
(resolve: (result: T) => void, reject: (error: Error) => void) => {
|
|
||||||
// we need some initial delay
|
// we need some initial delay
|
||||||
if (!started) {
|
if (!started) {
|
||||||
started = true;
|
started = true;
|
||||||
@@ -61,7 +60,16 @@ const waitForRestartAfter = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return promise.then((data) => new Promise<void>(executor(data)));
|
return promise
|
||||||
|
.catch(err => {
|
||||||
|
if (err instanceof BadGatewayError) {
|
||||||
|
// in some rare cases the reverse proxy stops forwarding traffic to scm before the response is returned
|
||||||
|
// in such a case the reverse proxy returns 502 (bad gateway), so we treat 502 not as error
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
})
|
||||||
|
.then(data => new Promise<void>(executor(data)));
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UseAvailablePluginsOptions = {
|
export type UseAvailablePluginsOptions = {
|
||||||
@@ -72,10 +80,10 @@ export const useAvailablePlugins = ({ enabled }: UseAvailablePluginsOptions = {}
|
|||||||
const indexLink = useRequiredIndexLink("availablePlugins");
|
const indexLink = useRequiredIndexLink("availablePlugins");
|
||||||
return useQuery<PluginCollection, Error>(
|
return useQuery<PluginCollection, Error>(
|
||||||
["plugins", "available"],
|
["plugins", "available"],
|
||||||
() => apiClient.get(indexLink).then((response) => response.json()),
|
() => apiClient.get(indexLink).then(response => response.json()),
|
||||||
{
|
{
|
||||||
enabled,
|
enabled,
|
||||||
retry: 3,
|
retry: 3
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -88,10 +96,10 @@ export const useInstalledPlugins = ({ enabled }: UseInstalledPluginsOptions = {}
|
|||||||
const indexLink = useRequiredIndexLink("installedPlugins");
|
const indexLink = useRequiredIndexLink("installedPlugins");
|
||||||
return useQuery<PluginCollection, Error>(
|
return useQuery<PluginCollection, Error>(
|
||||||
["plugins", "installed"],
|
["plugins", "installed"],
|
||||||
() => apiClient.get(indexLink).then((response) => response.json()),
|
() => apiClient.get(indexLink).then(response => response.json()),
|
||||||
{
|
{
|
||||||
enabled,
|
enabled,
|
||||||
retry: 3,
|
retry: 3
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -100,10 +108,10 @@ export const usePendingPlugins = (): ApiResult<PendingPlugins> => {
|
|||||||
const indexLink = useIndexLink("pendingPlugins");
|
const indexLink = useIndexLink("pendingPlugins");
|
||||||
return useQuery<PendingPlugins, Error>(
|
return useQuery<PendingPlugins, Error>(
|
||||||
["plugins", "pending"],
|
["plugins", "pending"],
|
||||||
() => apiClient.get(indexLink!).then((response) => response.json()),
|
() => apiClient.get(indexLink!).then(response => response.json()),
|
||||||
{
|
{
|
||||||
enabled: !!indexLink,
|
enabled: !!indexLink,
|
||||||
retry: 3,
|
retry: 3
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -135,19 +143,19 @@ export const useInstallPlugin = () => {
|
|||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => queryClient.invalidateQueries("plugins"),
|
onSuccess: () => queryClient.invalidateQueries("plugins")
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
install: (plugin: Plugin, restartOptions: RestartOptions = {}) =>
|
install: (plugin: Plugin, restartOptions: RestartOptions = {}) =>
|
||||||
mutate({
|
mutate({
|
||||||
plugin,
|
plugin,
|
||||||
restartOptions,
|
restartOptions
|
||||||
}),
|
}),
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
data,
|
data,
|
||||||
isInstalled: !!data,
|
isInstalled: !!data
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,18 +170,18 @@ export const useUninstallPlugin = () => {
|
|||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => queryClient.invalidateQueries("plugins"),
|
onSuccess: () => queryClient.invalidateQueries("plugins")
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
uninstall: (plugin: Plugin, restartOptions: RestartOptions = {}) =>
|
uninstall: (plugin: Plugin, restartOptions: RestartOptions = {}) =>
|
||||||
mutate({
|
mutate({
|
||||||
plugin,
|
plugin,
|
||||||
restartOptions,
|
restartOptions
|
||||||
}),
|
}),
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
isUninstalled: !!data,
|
isUninstalled: !!data
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,18 +204,18 @@ export const useUpdatePlugins = () => {
|
|||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => queryClient.invalidateQueries("plugins"),
|
onSuccess: () => queryClient.invalidateQueries("plugins")
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
update: (plugin: Plugin | PluginCollection, restartOptions: RestartOptions = {}) =>
|
update: (plugin: Plugin | PluginCollection, restartOptions: RestartOptions = {}) =>
|
||||||
mutate({
|
mutate({
|
||||||
plugins: plugin,
|
plugins: plugin,
|
||||||
restartOptions,
|
restartOptions
|
||||||
}),
|
}),
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
isUpdated: !!data,
|
isUpdated: !!data
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,7 +230,7 @@ export const useExecutePendingPlugins = () => {
|
|||||||
({ pending, restartOptions }) =>
|
({ pending, restartOptions }) =>
|
||||||
waitForRestartAfter(apiClient.post(requiredLink(pending, "execute")), restartOptions),
|
waitForRestartAfter(apiClient.post(requiredLink(pending, "execute")), restartOptions),
|
||||||
{
|
{
|
||||||
onSuccess: () => queryClient.invalidateQueries("plugins"),
|
onSuccess: () => queryClient.invalidateQueries("plugins")
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -230,22 +238,22 @@ export const useExecutePendingPlugins = () => {
|
|||||||
mutate({ pending, restartOptions }),
|
mutate({ pending, restartOptions }),
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
isExecuted: !!data,
|
isExecuted: !!data
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCancelPendingPlugins = () => {
|
export const useCancelPendingPlugins = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { mutate, isLoading, error, data } = useMutation<unknown, Error, PendingPlugins>(
|
const { mutate, isLoading, error, data } = useMutation<unknown, Error, PendingPlugins>(
|
||||||
(pending) => apiClient.post(requiredLink(pending, "cancel")),
|
pending => apiClient.post(requiredLink(pending, "cancel")),
|
||||||
{
|
{
|
||||||
onSuccess: () => queryClient.invalidateQueries("plugins"),
|
onSuccess: () => queryClient.invalidateQueries("plugins")
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
update: (pending: PendingPlugins) => mutate(pending),
|
update: (pending: PendingPlugins) => mutate(pending),
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
isCancelled: !!data,
|
isCancelled: !!data
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user