backend-app-api: introduce lifecycle middleware
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Introduced built-in middleware into the default `HttpService` implementation that throws a `ServiceNotAvailable` error when plugins aren't able to serve request. Also introduced a request stalling mechanism that pauses incoming request until plugins have been fully initialized.
|
||||
@@ -78,6 +78,11 @@ export function createHttpServer(
|
||||
},
|
||||
): Promise<ExtendedHttpServer>;
|
||||
|
||||
// @public
|
||||
export function createLifecycleMiddleware(
|
||||
options: LifecycleMiddlewareOptions,
|
||||
): RequestHandler;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createSpecializedBackend(
|
||||
options: CreateSpecializedBackendOptions,
|
||||
@@ -170,6 +175,14 @@ export const identityServiceFactory: (
|
||||
options?: IdentityFactoryOptions | undefined,
|
||||
) => ServiceFactory<IdentityService, 'plugin'>;
|
||||
|
||||
// @public
|
||||
export interface LifecycleMiddlewareOptions {
|
||||
// (undocumented)
|
||||
lifecycle: LifecycleService;
|
||||
// (undocumented)
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const lifecycleServiceFactory: () => ServiceFactory<
|
||||
LifecycleService,
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createLifecycleMiddleware } from './createLifecycleMiddleware';
|
||||
import { BackendLifecycleImpl } from '../rootLifecycle/rootLifecycleServiceFactory';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
import { ServiceUnavailableError } from '@backstage/errors';
|
||||
|
||||
describe('createLifecycleMiddleware', () => {
|
||||
it('should pause requests when plugin is not ready', async () => {
|
||||
const lifecycle = new BackendLifecycleImpl(mockServices.rootLogger());
|
||||
|
||||
const middleware = createLifecycleMiddleware({ lifecycle });
|
||||
|
||||
const next = jest.fn();
|
||||
middleware({} as any, {} as any, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
await lifecycle.startup();
|
||||
|
||||
// pending call
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
|
||||
// new call
|
||||
const next2 = jest.fn();
|
||||
middleware({} as any, {} as any, next2);
|
||||
expect(next2).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should throw ServiceUnavailableError after shutdown', async () => {
|
||||
const lifecycle = new BackendLifecycleImpl(mockServices.rootLogger());
|
||||
const middleware = createLifecycleMiddleware({ lifecycle });
|
||||
|
||||
const next = jest.fn();
|
||||
middleware({} as any, {} as any, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
await lifecycle.shutdown();
|
||||
|
||||
// pending call
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
new ServiceUnavailableError('Service is shutting down'),
|
||||
);
|
||||
|
||||
// new call
|
||||
const next2 = jest.fn();
|
||||
middleware({} as any, {} as any, next2);
|
||||
expect(next2).toHaveBeenCalledWith(
|
||||
new ServiceUnavailableError('Service is shutting down'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ServiceUnavailableError after timeout', async () => {
|
||||
const lifecycle = new BackendLifecycleImpl(mockServices.rootLogger());
|
||||
const middleware = createLifecycleMiddleware({ lifecycle, timeoutMs: 1 });
|
||||
|
||||
const next = jest.fn();
|
||||
middleware({} as any, {} as any, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
|
||||
await new Promise(r => setTimeout(r, 2));
|
||||
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
new ServiceUnavailableError('Service has not started up yet'),
|
||||
);
|
||||
});
|
||||
});
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LifecycleService } from '@backstage/backend-plugin-api';
|
||||
import { ServiceUnavailableError } from '@backstage/errors';
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Options for {@link createLifecycleMiddleware}.
|
||||
* @public
|
||||
*/
|
||||
export interface LifecycleMiddlewareOptions {
|
||||
lifecycle: LifecycleService;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a middleware that pauses requests until the service has started.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Requests that arrive before the service has started will be paused until startup is complete.
|
||||
* If the service does not start within the provided timeout, the request will be rejected with a
|
||||
* {@link @backstage/errors#ServiceUnavailableError}.
|
||||
*
|
||||
* If the service is shutting down, all requests will be rejected with a
|
||||
* {@link @backstage/errors#ServiceUnavailableError}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createLifecycleMiddleware(
|
||||
options: LifecycleMiddlewareOptions,
|
||||
): RequestHandler {
|
||||
const { lifecycle, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
|
||||
|
||||
let state: 'init' | 'up' | 'down' = 'init';
|
||||
const waiting = new Set<{
|
||||
next: (err?: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}>();
|
||||
|
||||
lifecycle.addStartupHook(async () => {
|
||||
if (state === 'init') {
|
||||
state = 'up';
|
||||
for (const item of waiting) {
|
||||
clearTimeout(item.timeout);
|
||||
item.next();
|
||||
}
|
||||
waiting.clear();
|
||||
}
|
||||
});
|
||||
|
||||
lifecycle.addShutdownHook(async () => {
|
||||
state = 'down';
|
||||
|
||||
for (const item of waiting) {
|
||||
clearTimeout(item.timeout);
|
||||
item.next(new ServiceUnavailableError('Service is shutting down'));
|
||||
}
|
||||
waiting.clear();
|
||||
});
|
||||
|
||||
return (_req, _res, next) => {
|
||||
if (state === 'up') {
|
||||
next();
|
||||
return;
|
||||
} else if (state === 'down') {
|
||||
next(new ServiceUnavailableError('Service is shutting down'));
|
||||
return;
|
||||
}
|
||||
|
||||
const item = {
|
||||
next,
|
||||
timeout: setTimeout(() => {
|
||||
if (waiting.delete(item)) {
|
||||
next(new ServiceUnavailableError('Service has not started up yet'));
|
||||
}
|
||||
}, timeoutMs),
|
||||
};
|
||||
|
||||
waiting.add(item);
|
||||
};
|
||||
}
|
||||
+12
-3
@@ -15,10 +15,12 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createServiceFactory,
|
||||
coreServices,
|
||||
createServiceFactory,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Handler } from 'express';
|
||||
import PromiseRouter from 'express-promise-router';
|
||||
import { createLifecycleMiddleware } from './createLifecycleMiddleware';
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -36,14 +38,21 @@ export const httpRouterServiceFactory = createServiceFactory(
|
||||
service: coreServices.httpRouter,
|
||||
deps: {
|
||||
plugin: coreServices.pluginMetadata,
|
||||
lifecycle: coreServices.lifecycle,
|
||||
rootHttpRouter: coreServices.rootHttpRouter,
|
||||
},
|
||||
async factory({ plugin, rootHttpRouter }) {
|
||||
async factory({ plugin, rootHttpRouter, lifecycle }) {
|
||||
const getPath = options?.getPath ?? (id => `/api/${id}`);
|
||||
const path = getPath(plugin.getId());
|
||||
|
||||
const router = PromiseRouter();
|
||||
rootHttpRouter.use(path, router);
|
||||
|
||||
router.use(createLifecycleMiddleware({ lifecycle }));
|
||||
|
||||
return {
|
||||
use(handler: Handler) {
|
||||
rootHttpRouter.use(path, handler);
|
||||
router.use(handler);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -16,3 +16,5 @@
|
||||
|
||||
export { httpRouterServiceFactory } from './httpRouterServiceFactory';
|
||||
export type { HttpRouterFactoryOptions } from './httpRouterServiceFactory';
|
||||
export { createLifecycleMiddleware } from './createLifecycleMiddleware';
|
||||
export type { LifecycleMiddlewareOptions } from './createLifecycleMiddleware';
|
||||
|
||||
Reference in New Issue
Block a user