diff --git a/.changeset/small-shoes-hide.md b/.changeset/small-shoes-hide.md new file mode 100644 index 0000000000..b1f95e9f2d --- /dev/null +++ b/.changeset/small-shoes-hide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend-node': patch +--- + +Exports `MissingIndexError` that can be used by the search engines for better error handling when missing index. diff --git a/.changeset/tender-chicken-learn.md b/.changeset/tender-chicken-learn.md new file mode 100644 index 0000000000..bd95c5545f --- /dev/null +++ b/.changeset/tender-chicken-learn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend': patch +--- + +If error is `MissingIndexError` we return a 400 response with a more clear error message. diff --git a/.changeset/two-owls-cry.md b/.changeset/two-owls-cry.md new file mode 100644 index 0000000000..ca371781a1 --- /dev/null +++ b/.changeset/two-owls-cry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend-module-elasticsearch': patch +--- + +Throws `MissingIndexError` when no index of type exist. diff --git a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts index cc88f456b6..b59c67c69c 100644 --- a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts +++ b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts @@ -793,6 +793,29 @@ describe('ElasticSearchSearchEngine', () => { elasticSearchQuerySpy.mockClear(); }); + + it('should throws missing index error', async () => { + jest.spyOn(clientWrapper, 'search').mockRejectedValue({ + meta: { + body: { + error: { + type: 'index_not_found_exception', + }, + }, + }, + }); + + await expect( + async () => + await testSearchEngine.query({ + term: 'testTerm', + types: ['unknown'], + filters: {}, + }), + ).rejects.toThrow( + 'Missing index for unknown__search. This means there are no documents to search through.', + ); + }); }); describe('indexer', () => { diff --git a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts index 4fa0d4e110..42c99dfe61 100644 --- a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts +++ b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts @@ -23,6 +23,7 @@ import { SearchEngine, SearchQuery, } from '@backstage/plugin-search-common'; +import { MissingIndexError } from '@backstage/plugin-search-backend-node'; import esb from 'elastic-builder'; import { isEmpty, isNaN as nan, isNumber } from 'lodash'; import { v4 as uuid } from 'uuid'; @@ -329,10 +330,16 @@ export class ElasticSearchSearchEngine implements SearchEngine { nextPageCursor, previousPageCursor, }; - } catch (e) { + } catch (error) { + if (error.meta?.body?.error?.type === 'index_not_found_exception') { + throw new MissingIndexError( + `Missing index for ${queryIndices}. This means there are no documents to search through.`, + error, + ); + } this.logger.error( `Failed to query documents for indices ${queryIndices}`, - e, + error, ); return Promise.reject({ results: [] }); } diff --git a/plugins/search-backend-node/api-report.md b/plugins/search-backend-node/api-report.md index 7649bc5d0f..ff9480c19d 100644 --- a/plugins/search-backend-node/api-report.md +++ b/plugins/search-backend-node/api-report.md @@ -113,6 +113,12 @@ export class LunrSearchEngineIndexer extends BatchSearchEngineIndexer { initialize(): Promise; } +// @public +export class MissingIndexError extends Error { + constructor(message?: string, cause?: Error | unknown); + readonly cause?: Error | undefined; +} + // @public export class NewlineDelimitedJsonCollatorFactory implements DocumentCollatorFactory diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts index 098d1e8b16..1f69460448 100644 --- a/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts +++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts @@ -949,6 +949,19 @@ describe('LunrSearchEngine', () => { previousPageCursor: undefined, }); }); + + it('should throws missing index error', async () => { + await expect( + async () => + await testLunrSearchEngine.query({ + term: 'testTerm', + types: ['unknown'], + filters: {}, + }), + ).rejects.toThrow( + "Missing index for unknown. This could be because the index hasn't been created yet or there was a problem during index creation.", + ); + }); }); it('should return previous page cursor if on another page', async () => { diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts index 03cb421653..ec8550d721 100644 --- a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts +++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts @@ -21,6 +21,7 @@ import { QueryTranslator, SearchEngine, } from '@backstage/plugin-search-common'; +import { MissingIndexError } from '../errors'; import lunr from 'lunr'; import { v4 as uuid } from 'uuid'; import { Logger } from 'winston'; @@ -163,30 +164,38 @@ export class LunrSearchEngine implements SearchEngine { const results: LunrResultEnvelope[] = []; + const indexKeys = Object.keys(this.lunrIndices).filter( + type => !documentTypes || documentTypes.includes(type), + ); + + if (documentTypes?.length && !indexKeys.length) { + throw new MissingIndexError( + `Missing index for ${documentTypes?.toString()}. This could be because the index hasn't been created yet or there was a problem during index creation.`, + ); + } + // Iterate over the filtered list of this.lunrIndex keys. - Object.keys(this.lunrIndices) - .filter(type => !documentTypes || documentTypes.includes(type)) - .forEach(type => { - try { - results.push( - ...this.lunrIndices[type].query(lunrQueryBuilder).map(result => { - return { - result: result, - type: type, - }; - }), - ); - } catch (err) { - // if a field does not exist on a index, we can see that as a no-match - if ( - err instanceof Error && - err.message.startsWith('unrecognised field') - ) { - return; - } - throw err; + indexKeys.forEach(type => { + try { + results.push( + ...this.lunrIndices[type].query(lunrQueryBuilder).map(result => { + return { + result: result, + type: type, + }; + }), + ); + } catch (err) { + // if a field does not exist on a index, we can see that as a no-match + if ( + err instanceof Error && + err.message.startsWith('unrecognised field') + ) { + return; } - }); + throw err; + } + }); // Sort results. results.sort((doc1, doc2) => { diff --git a/plugins/search-backend-node/src/errors.ts b/plugins/search-backend-node/src/errors.ts new file mode 100644 index 0000000000..00b168d460 --- /dev/null +++ b/plugins/search-backend-node/src/errors.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2021 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 { isError } from '@backstage/errors'; + +/** + * Failed to query documents for index that does not exist. + * @public + */ +export class MissingIndexError extends Error { + /** + * An inner error that caused this error to be thrown, if any. + */ + readonly cause?: Error | undefined; + + constructor(message?: string, cause?: Error | unknown) { + super(message); + + Error.captureStackTrace?.(this, this.constructor); + + this.name = this.constructor.name; + this.cause = isError(cause) ? cause : undefined; + } +} diff --git a/plugins/search-backend-node/src/index.ts b/plugins/search-backend-node/src/index.ts index d342bb9259..854881829c 100644 --- a/plugins/search-backend-node/src/index.ts +++ b/plugins/search-backend-node/src/index.ts @@ -34,6 +34,7 @@ export type { RegisterCollatorParameters, RegisterDecoratorParameters, } from './types'; +export * from './errors'; export * from './indexing'; export * from './test-utils'; diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index f54c2d388f..e4ac233406 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -19,7 +19,7 @@ import Router from 'express-promise-router'; import { Logger } from 'winston'; import { z } from 'zod'; import { errorHandler } from '@backstage/backend-common'; -import { InputError } from '@backstage/errors'; +import { ErrorResponseBody, InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; import { JsonObject, JsonValue } from '@backstage/types'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; @@ -129,7 +129,10 @@ export async function createRouter( const router = Router(); router.get( '/query', - async (req: express.Request, res: express.Response) => { + async ( + req: express.Request, + res: express.Response, + ) => { const parseResult = requestSchema.safeParse(req.query); if (!parseResult.success) { @@ -154,9 +157,14 @@ export async function createRouter( const resultSet = await engine?.query(query, { token }); res.send(filterResultSet(toSearchResults(resultSet))); - } catch (err) { + } catch (error) { + if (error.name === 'MissingIndexError') { + // re-throw and let the default error handler middleware captures it and serializes it with the right response code on the standard form + throw error; + } + throw new Error( - `There was a problem performing the search query. ${err}`, + `There was a problem performing the search query. ${error}`, ); } },