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:
Sebastian Sdorra
2022-02-02 10:02:46 +01:00
committed by GitHub
parent c155d1eb4a
commit dff5d3aa5b
5 changed files with 116 additions and 70 deletions

View 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))

View File

@@ -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();
}); });

View File

@@ -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;
}; };
} }

View File

@@ -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 {

View File

@@ -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
}; };
}; };