Merge pull request #12172 from backstage/emmai/search-error-handling

[Search] improve search error handling when missing index
This commit is contained in:
Camila Belo
2022-07-05 13:15:33 +02:00
committed by GitHub
11 changed files with 147 additions and 28 deletions
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend': patch
---
If error is `MissingIndexError` we return a 400 response with a more clear error message.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-module-elasticsearch': patch
---
Throws `MissingIndexError` when no index of type exist.
@@ -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', () => {
@@ -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: [] });
}
@@ -113,6 +113,12 @@ export class LunrSearchEngineIndexer extends BatchSearchEngineIndexer {
initialize(): Promise<void>;
}
// @public
export class MissingIndexError extends Error {
constructor(message?: string, cause?: Error | unknown);
readonly cause?: Error | undefined;
}
// @public
export class NewlineDelimitedJsonCollatorFactory
implements DocumentCollatorFactory
@@ -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 () => {
@@ -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) => {
+37
View File
@@ -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;
}
}
+1
View File
@@ -34,6 +34,7 @@ export type {
RegisterCollatorParameters,
RegisterDecoratorParameters,
} from './types';
export * from './errors';
export * from './indexing';
export * from './test-utils';
+12 -4
View File
@@ -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<SearchResultSet>) => {
async (
req: express.Request,
res: express.Response<SearchResultSet | ErrorResponseBody>,
) => {
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}`,
);
}
},