From 2c9f67e6f1663c4b28e33350168a40269cc2ce67 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 24 May 2023 16:11:35 +0200 Subject: [PATCH] backend-app-api: introduce lifecycle middleware Signed-off-by: Patrik Oldsberg --- .changeset/selfish-olives-end.md | 5 + packages/backend-app-api/api-report.md | 13 +++ .../createLifecycleMiddleware.test.ts | 78 +++++++++++++++ .../httpRouter/createLifecycleMiddleware.ts | 98 +++++++++++++++++++ .../httpRouter/httpRouterServiceFactory.ts | 15 ++- .../implementations/httpRouter/index.ts | 2 + 6 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 .changeset/selfish-olives-end.md create mode 100644 packages/backend-app-api/src/services/implementations/httpRouter/createLifecycleMiddleware.test.ts create mode 100644 packages/backend-app-api/src/services/implementations/httpRouter/createLifecycleMiddleware.ts diff --git a/.changeset/selfish-olives-end.md b/.changeset/selfish-olives-end.md new file mode 100644 index 0000000000..cb3778f316 --- /dev/null +++ b/.changeset/selfish-olives-end.md @@ -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. diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index 27b218ecb3..cde0ef64d2 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -78,6 +78,11 @@ export function createHttpServer( }, ): Promise; +// @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; +// @public +export interface LifecycleMiddlewareOptions { + // (undocumented) + lifecycle: LifecycleService; + // (undocumented) + timeoutMs?: number; +} + // @public export const lifecycleServiceFactory: () => ServiceFactory< LifecycleService, diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createLifecycleMiddleware.test.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createLifecycleMiddleware.test.ts new file mode 100644 index 0000000000..e1091d247f --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createLifecycleMiddleware.test.ts @@ -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'), + ); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createLifecycleMiddleware.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createLifecycleMiddleware.ts new file mode 100644 index 0000000000..b2a41bbb2f --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createLifecycleMiddleware.ts @@ -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); + }; +} diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts index bbd54b7ade..2ecdeffd77 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -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); }, }; }, diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/index.ts b/packages/backend-app-api/src/services/implementations/httpRouter/index.ts index 9dfb01c5be..928a232131 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/index.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/index.ts @@ -16,3 +16,5 @@ export { httpRouterServiceFactory } from './httpRouterServiceFactory'; export type { HttpRouterFactoryOptions } from './httpRouterServiceFactory'; +export { createLifecycleMiddleware } from './createLifecycleMiddleware'; +export type { LifecycleMiddlewareOptions } from './createLifecycleMiddleware';