Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions src/channel/httpBeaconChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {Logger} from '../logging';
import {NullLogger} from '../logging';
import type {CidAssigner} from '../cid';
import {formatMessage} from '../error';
import type {ApiProblem} from '../error';
import {CLIENT_LIBRARY} from '../constants';
import {Help} from '../help';

Expand All @@ -15,12 +16,11 @@ export type Configuration = {
logger?: Logger,
};

type ApiProblem = {
type: string,
title: string,
status: number,
detail: string,
};
export enum TrackingErrorType {
SUSPENDED_SERVICE = 'https://croct.help/sdk/javascript/suspended-service',
// Server errors
INTERNAL_ERROR = 'https://croct.help/api/event-tracking/internal-error',
}

export class HttpBeaconChannel implements DuplexChannel<string, Envelope<string, string>> {
private readonly configuration: Omit<Configuration, 'logger'>;
Expand Down Expand Up @@ -65,7 +65,7 @@ export class HttpBeaconChannel implements DuplexChannel<string, Envelope<string,
if (response.status === 202) {
this.logger.warn(
'Event tracking is currently suspended for this application, check the workspace settings. '
+ 'For help, see https://croct.help/sdk/javascript/suspended-service',
+ `For help, see ${TrackingErrorType.SUSPENDED_SERVICE}`,
);
}

Expand All @@ -76,14 +76,14 @@ export class HttpBeaconChannel implements DuplexChannel<string, Envelope<string,

const problem: ApiProblem = await response.json().catch(
() => ({
type: 'https://croct.help/api/event-tracker#unexpected-error',
type: TrackingErrorType.INTERNAL_ERROR,
title: response.statusText,
status: response.status,
}),
);

const isRetryable = HttpBeaconChannel.isRetryable(problem.status);
const help = Help.forStatusCode(problem.status);
const help = Help.forApiProblem(problem);

if (help !== undefined) {
this.logger.error(help);
Expand Down
54 changes: 37 additions & 17 deletions src/contentFetcher.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
import type {JsonObject} from '@croct/json';
import type {JsonObject, JsonPrimitive} from '@croct/json';
import type {ContentDefinitionBundle} from '@croct/content-model/definition';
import type {EvaluationContext} from './evaluator';
import type {Token} from './token';
import {BASE_ENDPOINT_URL, CLIENT_LIBRARY} from './constants';
import {formatMessage} from './error';
import type {ApiProblem} from './error';
import type {Logger} from './logging';
import {NullLogger} from './logging';
import {ApiKey} from './apiKey';
import {Help} from './help';

export type ErrorResponse = {
type: string,
title: string,
status: number,
detail?: string,
};

export enum ContentErrorType {
TIMEOUT = 'https://croct.help/sdk/javascript/request-timeout',
UNEXPECTED_ERROR = 'https://croct.help/sdk/javascript/unexpected-error',
SUSPENDED_SERVICE = 'https://croct.help/sdk/javascript/suspended-service',
Comment thread
renan628 marked this conversation as resolved.
// Server errors
INTERNAL_ERROR = 'https://croct.help/api/content/internal-error',
INVALID_PAYLOAD = 'https://croct.help/api/content/invalid-payload',
}

type FetchPayload = {
Expand All @@ -31,7 +27,7 @@ type FetchPayload = {
context?: EvaluationContext,
};

export class ContentError<T extends ErrorResponse = ErrorResponse> extends Error {
export class ContentError<T extends ApiProblem = ApiProblem> extends Error {
public readonly response: T;

public constructor(response: T) {
Expand All @@ -43,6 +39,24 @@ export class ContentError<T extends ErrorResponse = ErrorResponse> extends Error
}
}

export type InputViolation = {
path: string,
reason?: string,
details?: Record<string, JsonPrimitive>,
};

export type InputErrorResponse = ApiProblem & {
violations?: InputViolation[],
};

export class InputError extends ContentError<InputErrorResponse> {
public constructor(response: InputErrorResponse) {
super(response);

Object.setPrototypeOf(this, InputError.prototype);
}
}

export type FetchResponseOptions = {
includeSchema?: boolean,
};
Expand Down Expand Up @@ -164,7 +178,7 @@ export class ContentFetcher {
if (timeout !== undefined) {
timer = setTimeout(
() => {
const response: ErrorResponse = {
const response: ApiProblem = {
title: `Content could not be loaded in time for slot '${slotId}'.`,
type: ContentErrorType.TIMEOUT,
detail: `The content took more than ${timeout}ms to load.`,
Expand All @@ -173,7 +187,7 @@ export class ContentFetcher {

abortController.abort();

this.logHelp(response.status);
this.logHelp(response);

reject(new ContentError(response));
},
Expand Down Expand Up @@ -204,9 +218,15 @@ export class ContentFetcher {
return resolve(body);
}

this.logHelp(response.status);
const problem: ApiProblem = body;

this.logHelp(problem);

if (problem.type === ContentErrorType.INVALID_PAYLOAD) {
return reject(new InputError(problem as InputErrorResponse));
}

reject(new ContentError(body));
reject(new ContentError(problem));
})
.catch(error => {
if (!response.ok) {
Expand All @@ -221,7 +241,7 @@ export class ContentFetcher {
reject(
new ContentError({
title: formatMessage(error),
type: ContentErrorType.UNEXPECTED_ERROR,
type: ContentErrorType.INTERNAL_ERROR,
detail: 'Please try again or contact Croct support if the error persists.',
status: 500, // Internal Server Error
}),
Expand Down Expand Up @@ -311,8 +331,8 @@ export class ContentFetcher {
});
}

private logHelp(statusCode: number): void {
const help = Help.forStatusCode(statusCode);
private logHelp(problem: ApiProblem): void {
const help = Help.forApiProblem(problem);

if (help !== undefined) {
this.logger.error(help);
Expand Down
8 changes: 8 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
export type ApiProblem = {
title: string,
type: string,
status: number,
detail?: string,
instance?: string,
};

function extractMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
Expand Down
38 changes: 15 additions & 23 deletions src/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {JsonObject, JsonValue} from '@croct/json';
import type {Token} from './token';
import {BASE_ENDPOINT_URL, CLIENT_LIBRARY, MAX_QUERY_LENGTH} from './constants';
import {formatMessage} from './error';
import type {ApiProblem} from './error';
import type {Location} from './sourceLocation';
import {getLength, getLocation} from './sourceLocation';
import type {Logger} from './logging';
Expand Down Expand Up @@ -48,23 +49,15 @@ export type EvaluationOptions = {

export enum EvaluationErrorType {
TIMEOUT = 'https://croct.help/sdk/javascript/request-timeout',
UNEXPECTED_ERROR = 'https://croct.help/sdk/javascript/unexpected-error',
INVALID_QUERY = 'https://croct.help/sdk/javascript/invalid-query',
TOO_COMPLEX_QUERY = 'https://croct.help/sdk/javascript/too-complex-query',
EVALUATION_FAILED = 'https://croct.help/sdk/javascript/evaluation-failed',
UNALLOWED_RESULT = 'https://croct.help/sdk/javascript/unallowed-result',
Comment thread
renan628 marked this conversation as resolved.
SUSPENDED_SERVICE = 'https://croct.help/sdk/javascript/suspended-service',
UNSERIALIZABLE_RESULT = 'https://croct.help/sdk/javascript/unserializable-result',
Comment thread
renan628 marked this conversation as resolved.
TOO_COMPLEX_QUERY = 'https://croct.help/sdk/javascript/too-complex-query',
// Server errors
INTERNAL_ERROR = 'https://croct.help/api/evaluation/internal-error',
INVALID_QUERY = 'https://croct.help/api/evaluation/invalid-query',
EVALUATION_FAILED = 'https://croct.help/api/evaluation/evaluation-failed',
}

export type ErrorResponse = {
type: EvaluationErrorType,
title: string,
status: number,
detail?: string,
};

export class EvaluationError<T extends ErrorResponse = ErrorResponse> extends Error {
export class EvaluationError<T extends ApiProblem = ApiProblem> extends Error {
public readonly response: T;

public constructor(response: T) {
Expand All @@ -81,7 +74,7 @@ type QueryErrorDetail = {
location: Location,
};

export type QueryErrorResponse = ErrorResponse & {
export type QueryErrorResponse = ApiProblem & {
errors: QueryErrorDetail[],
};

Expand Down Expand Up @@ -174,7 +167,7 @@ export class Evaluator {
if (timeout !== undefined) {
timer = setTimeout(
() => {
const response: ErrorResponse = {
const response: ApiProblem = {
title: `Evaluation could not be completed in time for query "${reference}".`,
type: EvaluationErrorType.TIMEOUT,
detail: `The evaluation took more than ${timeout}ms to complete.`,
Expand All @@ -183,7 +176,7 @@ export class Evaluator {

abortController.abort();

this.logHelp(response.status);
this.logHelp(response);

reject(new EvaluationError(response));
},
Expand Down Expand Up @@ -217,14 +210,13 @@ export class Evaluator {
return resolve(body);
}

this.logHelp(response.status);
const problem: ApiProblem = body;

const problem: ErrorResponse = body;
this.logHelp(problem);

switch (problem.type) {
case EvaluationErrorType.INVALID_QUERY:
case EvaluationErrorType.EVALUATION_FAILED:
case EvaluationErrorType.TOO_COMPLEX_QUERY:
reject(new QueryError(problem as QueryErrorResponse));

break;
Expand All @@ -250,7 +242,7 @@ export class Evaluator {
reject(
new EvaluationError({
title: formatMessage(error),
type: EvaluationErrorType.UNEXPECTED_ERROR,
type: EvaluationErrorType.INTERNAL_ERROR,
detail: 'Please try again or contact Croct support if the error persists.',
status: 500, // Internal Server Error
}),
Expand Down Expand Up @@ -310,8 +302,8 @@ export class Evaluator {
});
}

private logHelp(statusCode: number): void {
const help = Help.forStatusCode(statusCode);
private logHelp(problem: ApiProblem): void {
const help = Help.forApiProblem(problem);

if (help !== undefined) {
this.logger.error(help);
Expand Down
30 changes: 21 additions & 9 deletions src/help.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
import type {ApiProblem} from './error';

enum AuthErrorType {
FORBIDDEN_ORIGIN = 'https://croct.help/api/authentication/forbidden-origin',
QUOTA_EXCEEDED = 'https://croct.help/api/authentication/quota-exceeded',
}

export namespace Help {
export function forStatusCode(statusCode: 204 | 401 | 403 | 408 | 423): string;
export function forApiProblem(problem: ApiProblem): string | undefined {
switch (problem.type) {
case AuthErrorType.FORBIDDEN_ORIGIN:
return 'The origin of the request is not allowed in your application settings. '
+ 'For help, see https://croct.help/sdk/javascript/unauthorized-origin';
case AuthErrorType.QUOTA_EXCEEDED:
return 'The application has exceeded the monthly active users (MAU) quota. '
+ 'For help, see https://croct.help/sdk/javascript/mau-exceeded';
default:
return Help.forStatusCode(problem.status);
}
}

export function forStatusCode(statusCode: 202 | 401 | 408): string;

export function forStatusCode(statusCode: number): string | undefined;

Expand All @@ -13,18 +33,10 @@ export namespace Help {
return 'The request was not authorized, most likely due to invalid credentials. '
+ 'For help, see https://croct.help/sdk/javascript/invalid-credentials';

case 403:
return 'The origin of the request is not allowed in your application settings. '
+ 'For help, see https://croct.help/sdk/javascript/unauthorized-origin';

case 408:
return 'The request timed out. '
+ 'For help, see https://croct.help/sdk/javascript/request-timeout';

case 423:
return 'The application has exceeded the monthly active users (MAU) quota. '
+ 'For help, see https://croct.help/sdk/javascript/mau-exceeded';

default:
return undefined;
}
Expand Down
14 changes: 9 additions & 5 deletions test/channel/httpBeaconChannel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ describe('An HTTP beacon channel', () => {

type NonRetryableErrorScenario = {
status: number,
type?: string,
title: string,
log?: string,
};
Expand All @@ -209,22 +210,25 @@ describe('An HTTP beacon channel', () => {
},
{
status: 403,
title: 'Unallowed origin',
type: 'https://croct.help/api/authentication/quota-exceeded',
title: 'Quota exceeded',
},
{
status: 423,
title: 'Quota exceeded',
status: 403,
type: 'https://croct.help/api/authentication/forbidden-origin',
title: 'Unallowed origin',
},
])('should report a non-retryable error if the response status is $status', async scenario => {
const {status, title} = scenario;
const log = scenario.log ?? Help.forStatusCode(status);
const type = scenario.type ?? 'https://croct.help/api/event-tracker#error';
const log = scenario.log ?? Help.forApiProblem({status: status, type: type, title: title});

expect(log).toBeDefined();

fetchMock.mockGlobal().route(endpointUrl, {
status: status,
body: JSON.stringify({
type: 'https://croct.help/api/event-tracker#error',
type: type,
title: title,
status: status,
}),
Expand Down
Loading
Loading