Datasources: Improve error handling for testing data sources (#35120)

* Improve error handling for error messages

The error message will be read from error object from the following properties in the following order:
- message
- data.message
- statusText

* Convert api/ds/query errors to TestingStatus

SQL datasources (mysql, mssql, postgres) and CloudWatch use api/ds/query to test the data source, but previously didn't handle errors returned by this endpoint. If the error cannot be handled it's re-thrown to be handled in public/app/features/datasources/state/actions.ts

* Use async/await instead of Promises

* Remove incorrect type import

TestingStatus is in app/types. Should be pulled down to grafana/data but it depends on HealthCheckResultDetails that is public and lives in grafana/runtime. Ideally TestingStatus should live in grafana/data but I'm not sure if HealthCheckResultDetails can be move there too (?)

* Update packages/grafana-data/src/types/datasource.ts

Co-authored-by: Erik Sundell <erik.sundell@grafana.com>

* Handle errors with no details in toTestingStatus instead of re-throwing

* Update packages/grafana-data/src/types/datasource.ts

Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>

Co-authored-by: Erik Sundell <erik.sundell@grafana.com>
Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
This commit is contained in:
Piotr Jamróz
2021-07-08 14:32:27 +02:00
committed by GitHub
parent 9b2d7d6d69
commit 9ace8686a1
10 changed files with 170 additions and 32 deletions

View File

@ -228,7 +228,11 @@ abstract class DataSourceApi<
abstract query(request: DataQueryRequest<TQuery>): Promise<DataQueryResponse> | Observable<DataQueryResponse>; abstract query(request: DataQueryRequest<TQuery>): Promise<DataQueryResponse> | Observable<DataQueryResponse>;
/** /**
* Test & verify datasource settings & connection details * Test & verify datasource settings & connection details (returning TestingStatus)
*
* When verification fails - errors specific to the data source should be handled here and converted to
* a TestingStatus object. Unknown errors and HTTP errors can be re-thrown and will be handled here:
* public/app/features/datasources/state/actions.ts
*/ */
abstract testDatasource(): Promise<any>; abstract testDatasource(): Promise<any>;
@ -472,7 +476,13 @@ export enum DataQueryErrorType {
export interface DataQueryError { export interface DataQueryError {
data?: { data?: {
/**
* Short information about the error
*/
message?: string; message?: string;
/**
* Detailed information about the error. Only returned when app_mode is development.
*/
error?: string; error?: string;
}; };
message?: string; message?: string;

View File

@ -1,6 +1,6 @@
import { DataQuery, toDataFrameDTO, DataFrame } from '@grafana/data'; import { DataQuery, toDataFrameDTO, DataFrame } from '@grafana/data';
import { FetchResponse } from 'src/services'; import { FetchError, FetchResponse } from 'src/services';
import { BackendDataSourceResponse, toDataQueryResponse } from './queryResponse'; import { BackendDataSourceResponse, toDataQueryResponse, toTestingStatus } from './queryResponse';
const resp = ({ const resp = ({
data: { data: {
@ -333,4 +333,44 @@ describe('Query Response parser', () => {
] ]
`); `);
}); });
describe('should convert to TestingStatus', () => {
test('from api/ds/query generic errors', () => {
const result = toTestingStatus({ status: 500, data: { message: 'message', error: 'error' } } as FetchError);
expect(result).toMatchObject({
status: 'error',
message: 'message',
details: { message: 'error' },
});
});
test('from api/ds/query result errors', () => {
const result = toTestingStatus({
status: 400,
data: {
results: {
A: {
error: 'error',
},
},
},
} as FetchError);
expect(result).toMatchObject({
status: 'error',
message: 'error',
});
});
test('unknown errors', () => {
expect(() => {
toTestingStatus({ status: 503, data: 'Fatal Error' } as FetchError);
}).toThrow();
expect(() => {
toTestingStatus({ status: 503, data: {} } as FetchError);
}).toThrow();
expect(() => {
toTestingStatus({ status: 503 } as FetchError);
}).toThrow();
});
});
}); });

View File

@ -13,7 +13,7 @@ import {
DataFrameJSON, DataFrameJSON,
dataFrameFromJSON, dataFrameFromJSON,
} from '@grafana/data'; } from '@grafana/data';
import { FetchResponse } from '../services'; import { FetchError, FetchResponse } from '../services';
/** /**
* Single response object from a backend data source. Properties are optional but response should contain at least * Single response object from a backend data source. Properties are optional but response should contain at least
@ -127,6 +127,38 @@ export function toDataQueryResponse(
return rsp; return rsp;
} }
/**
* Data sources using api/ds/query to test data sources can use this function to
* handle errors and convert them to TestingStatus object.
*
* If possible, this should be avoided in favor of implementing /health endpoint
* and testing data source with DataSourceWithBackend.testDataSource()
*
* Re-thrown errors are handled by testDataSource() in public/app/features/datasources/state/actions.ts
*
* @returns {TestingStatus}
*/
export function toTestingStatus(err: FetchError): any {
const queryResponse = toDataQueryResponse(err);
// POST api/ds/query errors returned as { message: string, error: string } objects
if (queryResponse.error?.data?.message) {
return {
status: 'error',
message: queryResponse.error.data.message,
details: queryResponse.error?.data?.error ? { message: queryResponse.error.data.error } : undefined,
};
}
// POST api/ds/query errors returned in results object
else if (queryResponse.error?.refId && queryResponse.error?.message) {
return {
status: 'error',
message: queryResponse.error.message,
};
}
throw err;
}
/** /**
* Convert an object into a DataQueryError -- if this is an HTTP response, * Convert an object into a DataQueryError -- if this is an HTTP response,
* it will put the correct values in the error field * it will put the correct values in the error field

View File

@ -299,14 +299,19 @@ export class BackendSrv implements BackendService {
]); ]);
} }
processRequestError(options: BackendSrvRequest, err: FetchError): FetchError { /**
* Processes FetchError to ensure "data" property is an object.
*
* @see DataQueryError.data
*/
processRequestError(options: BackendSrvRequest, err: FetchError): FetchError<{ message: string; error?: string }> {
err.data = err.data ?? { message: 'Unexpected error' }; err.data = err.data ?? { message: 'Unexpected error' };
if (typeof err.data === 'string') { if (typeof err.data === 'string') {
err.data = { err.data = {
message: err.data,
error: err.statusText, error: err.statusText,
response: err.data, response: err.data,
message: err.data,
}; };
} }

View File

@ -29,6 +29,31 @@ const getBackendSrvMock = () =>
withNoBackendCache: jest.fn().mockImplementationOnce((cb) => cb()), withNoBackendCache: jest.fn().mockImplementationOnce((cb) => cb()),
} as any); } as any);
const failDataSourceTest = async (error: object) => {
const dependencies: TestDataSourceDependencies = {
getDatasourceSrv: () =>
({
get: jest.fn().mockReturnValue({
testDatasource: jest.fn().mockImplementation(() => {
throw error;
}),
}),
} as any),
getBackendSrv: getBackendSrvMock,
};
const state = {
testingStatus: {
message: '',
status: '',
},
};
const dispatchedActions = await thunkTester(state)
.givenThunk(testDataSource)
.whenThunkIsDispatched('Azure Monitor', dependencies);
return dispatchedActions;
};
describe('Name exists', () => { describe('Name exists', () => {
const plugins = getMockPlugins(5); const plugins = getMockPlugins(5);
@ -194,5 +219,36 @@ describe('testDataSource', () => {
expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]); expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]);
}); });
it('then testDataSourceFailed should be dispatched with response error message', async () => {
const result = {
message: 'Error testing datasource',
};
const dispatchedActions = await failDataSourceTest({
message: 'Error testing datasource',
data: { message: 'Response error message' },
statusText: 'Bad Request',
});
expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]);
});
it('then testDataSourceFailed should be dispatched with response data message', async () => {
const result = {
message: 'Response error message',
};
const dispatchedActions = await failDataSourceTest({
data: { message: 'Response error message' },
statusText: 'Bad Request',
});
expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]);
});
it('then testDataSourceFailed should be dispatched with response statusText', async () => {
const result = {
message: 'HTTP error Bad Request',
};
const dispatchedActions = await failDataSourceTest({ data: {}, statusText: 'Bad Request' });
expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]);
});
}); });
}); });

View File

@ -99,8 +99,9 @@ export const testDataSource = (
dispatch(testDataSourceSucceeded(result)); dispatch(testDataSourceSucceeded(result));
} catch (err) { } catch (err) {
const { statusText, message: errMessage, details } = err; const { statusText, message: errMessage, details, data } = err;
const message = statusText ? 'HTTP error ' + statusText : errMessage;
const message = errMessage || data?.message || 'HTTP error ' + statusText;
dispatch(testDataSourceFailed({ message, details })); dispatch(testDataSourceFailed({ message, details }));
} }

View File

@ -64,6 +64,7 @@ import { CloudWatchLanguageProvider } from './language_provider';
import { VariableWithMultiSupport } from 'app/features/variables/types'; import { VariableWithMultiSupport } from 'app/features/variables/types';
import { AwsUrl, encodeUrl } from './aws_url'; import { AwsUrl, encodeUrl } from './aws_url';
import { increasingInterval } from './utils/rxjs/increasingInterval'; import { increasingInterval } from './utils/rxjs/increasingInterval';
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse';
import config from 'app/core/config'; import config from 'app/core/config';
const DS_QUERY_ENDPOINT = '/api/ds/query'; const DS_QUERY_ENDPOINT = '/api/ds/query';
@ -878,17 +879,22 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
); );
} }
testDatasource() { async testDatasource() {
// use billing metrics for test // use billing metrics for test
const region = this.defaultRegion; const region = this.defaultRegion;
const namespace = 'AWS/Billing'; const namespace = 'AWS/Billing';
const metricName = 'EstimatedCharges'; const metricName = 'EstimatedCharges';
const dimensions = {}; const dimensions = {};
return this.getDimensionValues(region, namespace, metricName, 'ServiceName', dimensions).then(() => ({ try {
await this.getDimensionValues(region, namespace, metricName, 'ServiceName', dimensions);
return {
status: 'success', status: 'success',
message: 'Data source is working', message: 'Data source is working',
})); };
} catch (error) {
return toTestingStatus(error);
}
} }
awsRequest(url: string, data: MetricRequest): Observable<TSDBResponse> { awsRequest(url: string, data: MetricRequest): Observable<TSDBResponse> {

View File

@ -8,6 +8,7 @@ import ResponseParser from './response_parser';
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
import { MssqlQueryForInterpolation, MssqlQuery, MssqlOptions } from './types'; import { MssqlQueryForInterpolation, MssqlQuery, MssqlOptions } from './types';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse';
export class MssqlDatasource extends DataSourceWithBackend<MssqlQuery, MssqlOptions> { export class MssqlDatasource extends DataSourceWithBackend<MssqlQuery, MssqlOptions> {
id: any; id: any;
@ -172,12 +173,7 @@ export class MssqlDatasource extends DataSourceWithBackend<MssqlQuery, MssqlOpti
.pipe( .pipe(
mapTo({ status: 'success', message: 'Database Connection OK' }), mapTo({ status: 'success', message: 'Database Connection OK' }),
catchError((err) => { catchError((err) => {
console.error(err); return of(toTestingStatus(err));
if (err.data && err.data.message) {
return of({ status: 'error', message: err.data.message });
}
return of({ status: 'error', message: err.status });
}) })
) )
.toPromise(); .toPromise();

View File

@ -1,5 +1,4 @@
import { map as _map } from 'lodash'; import { map as _map } from 'lodash';
import { of } from 'rxjs';
import { catchError, map, mapTo } from 'rxjs/operators'; import { catchError, map, mapTo } from 'rxjs/operators';
import { getBackendSrv, DataSourceWithBackend, FetchResponse, BackendDataSourceResponse } from '@grafana/runtime'; import { getBackendSrv, DataSourceWithBackend, FetchResponse, BackendDataSourceResponse } from '@grafana/runtime';
import { DataSourceInstanceSettings, ScopedVars, MetricFindValue, AnnotationEvent } from '@grafana/data'; import { DataSourceInstanceSettings, ScopedVars, MetricFindValue, AnnotationEvent } from '@grafana/data';
@ -9,6 +8,8 @@ import { MysqlQueryForInterpolation, MySQLOptions, MySQLQuery } from './types';
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
import { getSearchFilterScopedVar } from '../../../features/variables/utils'; import { getSearchFilterScopedVar } from '../../../features/variables/utils';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { of } from 'rxjs';
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse';
export class MysqlDatasource extends DataSourceWithBackend<MySQLQuery, MySQLOptions> { export class MysqlDatasource extends DataSourceWithBackend<MySQLQuery, MySQLOptions> {
id: any; id: any;
@ -181,12 +182,7 @@ export class MysqlDatasource extends DataSourceWithBackend<MySQLQuery, MySQLOpti
.pipe( .pipe(
mapTo({ status: 'success', message: 'Database Connection OK' }), mapTo({ status: 'success', message: 'Database Connection OK' }),
catchError((err) => { catchError((err) => {
console.error(err); return of(toTestingStatus(err));
if (err.data && err.data.message) {
return of({ status: 'error', message: err.data.message });
} else {
return of({ status: 'error', message: err.status });
}
}) })
) )
.toPromise(); .toPromise();

View File

@ -10,6 +10,7 @@ import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
//Types //Types
import { PostgresOptions, PostgresQuery, PostgresQueryForInterpolation } from './types'; import { PostgresOptions, PostgresQuery, PostgresQueryForInterpolation } from './types';
import { getSearchFilterScopedVar } from '../../../features/variables/utils'; import { getSearchFilterScopedVar } from '../../../features/variables/utils';
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse';
export class PostgresDatasource extends DataSourceWithBackend<PostgresQuery, PostgresOptions> { export class PostgresDatasource extends DataSourceWithBackend<PostgresQuery, PostgresOptions> {
id: any; id: any;
@ -174,12 +175,7 @@ export class PostgresDatasource extends DataSourceWithBackend<PostgresQuery, Pos
return { status: 'success', message: 'Database Connection OK' }; return { status: 'success', message: 'Database Connection OK' };
}) })
.catch((err: any) => { .catch((err: any) => {
console.error(err); return toTestingStatus(err);
if (err.data && err.data.message) {
return { status: 'error', message: err.data.message };
} else {
return { status: 'error', message: err.status };
}
}); });
} }