From fd981ad1bf9ecca7e6f379646f4963014398206a Mon Sep 17 00:00:00 2001 From: Renan Date: Mon, 15 Jun 2026 15:22:28 -0300 Subject: [PATCH 1/6] WIP --- src/evaluator.ts | 14 ++++++-------- src/help.ts | 10 +++++----- test/channel/httpBeaconChannel.test.ts | 6 +++--- test/contentFetcher.test.ts | 6 +++--- test/evaluator.test.ts | 14 +++++++------- test/help.test.ts | 8 ++++---- 6 files changed, 28 insertions(+), 30 deletions(-) diff --git a/src/evaluator.ts b/src/evaluator.ts index 13aaf50f..f03ac667 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -48,13 +48,12 @@ 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', + INTERNAL_ERROR = 'https://croct.help/sdk/javascript/internal-error', SUSPENDED_SERVICE = 'https://croct.help/sdk/javascript/suspended-service', - UNSERIALIZABLE_RESULT = 'https://croct.help/sdk/javascript/unserializable-result', + TOO_COMPLEX_QUERY = 'https://croct.help/sdk/javascript/too-complex-query', + // Server errors + INVALID_QUERY = 'https://croct.help/api/evaluation/invalid-query', + EVALUATION_FAILED = 'https://croct.help/api/evaluation/evaluation-failed', } export type ErrorResponse = { @@ -224,7 +223,6 @@ export class Evaluator { switch (problem.type) { case EvaluationErrorType.INVALID_QUERY: case EvaluationErrorType.EVALUATION_FAILED: - case EvaluationErrorType.TOO_COMPLEX_QUERY: reject(new QueryError(problem as QueryErrorResponse)); break; @@ -250,7 +248,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 }), diff --git a/src/help.ts b/src/help.ts index 0cbaafc2..9ba5e725 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,5 +1,5 @@ export namespace Help { - export function forStatusCode(statusCode: 204 | 401 | 403 | 408 | 423): string; + export function forStatusCode(statusCode: 204 | 401 | 403 | 408 | 402): string; export function forStatusCode(statusCode: number): string | undefined; @@ -13,6 +13,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 application has exceeded the monthly active users (MAU) quota. ' + + 'For help, see https://croct.help/sdk/javascript/mau-exceeded'; + 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'; @@ -21,10 +25,6 @@ export namespace Help { 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; } diff --git a/test/channel/httpBeaconChannel.test.ts b/test/channel/httpBeaconChannel.test.ts index 3b35a3b2..79a9a7d0 100644 --- a/test/channel/httpBeaconChannel.test.ts +++ b/test/channel/httpBeaconChannel.test.ts @@ -209,11 +209,11 @@ describe('An HTTP beacon channel', () => { }, { status: 403, - title: 'Unallowed origin', + title: 'Quota exceeded', }, { - status: 423, - title: 'Quota exceeded', + status: 403, + title: 'Unallowed origin', }, ])('should report a non-retryable error if the response status is $status', async scenario => { const {status, title} = scenario; diff --git a/test/contentFetcher.test.ts b/test/contentFetcher.test.ts index 63cd966c..ee34d955 100644 --- a/test/contentFetcher.test.ts +++ b/test/contentFetcher.test.ts @@ -752,11 +752,11 @@ describe('A content fetcher', () => { }, { status: 403, - title: 'Unallowed origin', + title: 'Quota exceeded', }, { - status: 423, - title: 'Quota exceeded', + status: 403, + title: 'Unallowed origin', }, ])('should log help messages for status code $status', async scenario => { const logger: Logger = { diff --git a/test/evaluator.test.ts b/test/evaluator.test.ts index 62ad5b38..d1502e9c 100644 --- a/test/evaluator.test.ts +++ b/test/evaluator.test.ts @@ -538,7 +538,7 @@ describe('An evaluator', () => { const response: ErrorResponse = { title: 'Error 500 - Internal Server Error', - type: EvaluationErrorType.UNEXPECTED_ERROR, + type: EvaluationErrorType.INTERNAL_ERROR, detail: 'Please try again or contact Croct support if the error persists.', status: 500, }; @@ -564,7 +564,7 @@ describe('An evaluator', () => { const response: ErrorResponse = { title: 'Unknown error', - type: EvaluationErrorType.UNEXPECTED_ERROR, + type: EvaluationErrorType.INTERNAL_ERROR, detail: 'Please try again or contact Croct support if the error persists.', status: 500, }; @@ -589,7 +589,7 @@ describe('An evaluator', () => { const response: ErrorResponse = { title: 'Network error.', - type: EvaluationErrorType.UNEXPECTED_ERROR, + type: EvaluationErrorType.INTERNAL_ERROR, detail: 'Please try again or contact Croct support if the error persists.', status: 500, }; @@ -619,11 +619,11 @@ describe('An evaluator', () => { }, { status: 403, - title: 'Unallowed origin', + title: 'Quota exceeded', }, { - status: 423, - title: 'Quota exceeded', + status: 403, + title: 'Unallowed origin', }, ])('should log help messages for status code $status', async scenario => { const logger: Logger = { @@ -640,7 +640,7 @@ describe('An evaluator', () => { const response: ErrorResponse = { title: scenario.title, - type: EvaluationErrorType.UNEXPECTED_ERROR, + type: EvaluationErrorType.INTERNAL_ERROR, status: scenario.status, }; diff --git a/test/help.test.ts b/test/help.test.ts index 7678da1d..5d51493c 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -15,6 +15,10 @@ describe('A function to provide help for errors', () => { status: 401, help: 'https://croct.help/sdk/javascript/invalid-credentials', }, + { + status: 403, + help: 'https://croct.help/sdk/javascript/mau-exceeded', + }, { status: 403, help: 'https://croct.help/sdk/javascript/unauthorized-origin', @@ -23,10 +27,6 @@ describe('A function to provide help for errors', () => { status: 408, help: 'https://croct.help/sdk/javascript/request-timeout', }, - { - status: 423, - help: 'https://croct.help/sdk/javascript/mau-exceeded', - }, ])('should provide help for status code %i', scenario => { expect(Help.forStatusCode(scenario.status)).toContain(scenario.help); }); From ab36b2d18d525236637f61a7930d963e207e52f2 Mon Sep 17 00:00:00 2001 From: Renan Date: Mon, 15 Jun 2026 15:45:51 -0300 Subject: [PATCH 2/6] WIP --- src/channel/httpBeaconChannel.ts | 9 ++++++-- src/contentFetcher.ts | 35 ++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/channel/httpBeaconChannel.ts b/src/channel/httpBeaconChannel.ts index 79021bd6..233a4f85 100644 --- a/src/channel/httpBeaconChannel.ts +++ b/src/channel/httpBeaconChannel.ts @@ -22,6 +22,11 @@ type ApiProblem = { detail: string, }; +export enum TrackingErrorType { + INTERNAL_ERROR = 'https://croct.help/sdk/javascript/internal-error', + SUSPENDED_SERVICE = 'https://croct.help/sdk/javascript/suspended-service', +} + export class HttpBeaconChannel implements DuplexChannel> { private readonly configuration: Omit; @@ -65,7 +70,7 @@ export class HttpBeaconChannel implements DuplexChannel ({ - type: 'https://croct.help/api/event-tracker#unexpected-error', + type: TrackingErrorType.INTERNAL_ERROR, title: response.statusText, status: response.status, }), diff --git a/src/contentFetcher.ts b/src/contentFetcher.ts index a5afeca2..6b2e17d0 100644 --- a/src/contentFetcher.ts +++ b/src/contentFetcher.ts @@ -1,4 +1,4 @@ -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'; @@ -18,8 +18,10 @@ export type ErrorResponse = { export enum ContentErrorType { TIMEOUT = 'https://croct.help/sdk/javascript/request-timeout', - UNEXPECTED_ERROR = 'https://croct.help/sdk/javascript/unexpected-error', + INTERNAL_ERROR = 'https://croct.help/sdk/javascript/internal-error', SUSPENDED_SERVICE = 'https://croct.help/sdk/javascript/suspended-service', + // Server errors + INVALID_PAYLOAD = 'https://croct.help/api/content/invalid-payload', } type FetchPayload = { @@ -43,6 +45,25 @@ export class ContentError extends Error } } +export type InputViolation = { + path: string, + reason?: string, + details?: Record, +}; + +export type InputErrorResponse = ErrorResponse & { + violations?: InputViolation[], +}; + +export class InputError extends ContentError { + public constructor(response: InputErrorResponse) { + super(response); + + Object.setPrototypeOf(this, InputError.prototype); + } +} + + export type FetchResponseOptions = { includeSchema?: boolean, }; @@ -206,7 +227,13 @@ export class ContentFetcher { this.logHelp(response.status); - reject(new ContentError(body)); + const problem: ErrorResponse = body; + + if (problem.type === ContentErrorType.INVALID_PAYLOAD) { + return reject(new InputError(problem as InputErrorResponse)); + } + + reject(new ContentError(problem)); }) .catch(error => { if (!response.ok) { @@ -221,7 +248,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 }), From 8907b91fb4401acc942f774e2fb4462f0273983a Mon Sep 17 00:00:00 2001 From: Renan Date: Mon, 15 Jun 2026 16:22:15 -0300 Subject: [PATCH 3/6] WIP --- src/channel/httpBeaconChannel.ts | 11 ++-------- src/contentFetcher.ts | 25 ++++++++-------------- src/error.ts | 8 +++++++ src/evaluator.ts | 25 ++++++++-------------- src/help.ts | 36 ++++++++++++++++++++++++-------- 5 files changed, 55 insertions(+), 50 deletions(-) diff --git a/src/channel/httpBeaconChannel.ts b/src/channel/httpBeaconChannel.ts index 233a4f85..edf9e886 100644 --- a/src/channel/httpBeaconChannel.ts +++ b/src/channel/httpBeaconChannel.ts @@ -4,7 +4,7 @@ import type {Envelope} from './guaranteedChannel'; import type {Logger} from '../logging'; import {NullLogger} from '../logging'; import type {CidAssigner} from '../cid'; -import {formatMessage} from '../error'; +import {ApiProblem, formatMessage} from '../error'; import {CLIENT_LIBRARY} from '../constants'; import {Help} from '../help'; @@ -15,13 +15,6 @@ export type Configuration = { logger?: Logger, }; -type ApiProblem = { - type: string, - title: string, - status: number, - detail: string, -}; - export enum TrackingErrorType { INTERNAL_ERROR = 'https://croct.help/sdk/javascript/internal-error', SUSPENDED_SERVICE = 'https://croct.help/sdk/javascript/suspended-service', @@ -88,7 +81,7 @@ export class HttpBeaconChannel implements DuplexChannel extends Error { +export class ContentError extends Error { public readonly response: T; public constructor(response: T) { @@ -51,7 +44,7 @@ export type InputViolation = { details?: Record, }; -export type InputErrorResponse = ErrorResponse & { +export type InputErrorResponse = ApiProblem & { violations?: InputViolation[], }; @@ -185,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.`, @@ -194,7 +187,7 @@ export class ContentFetcher { abortController.abort(); - this.logHelp(response.status); + this.logHelp(response); reject(new ContentError(response)); }, @@ -225,9 +218,9 @@ export class ContentFetcher { return resolve(body); } - this.logHelp(response.status); + const problem: ApiProblem = body; - const problem: ErrorResponse = body; + this.logHelp(problem); if (problem.type === ContentErrorType.INVALID_PAYLOAD) { return reject(new InputError(problem as InputErrorResponse)); @@ -338,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); diff --git a/src/error.ts b/src/error.ts index 9db8c292..dadefa5f 100644 --- a/src/error.ts +++ b/src/error.ts @@ -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; diff --git a/src/evaluator.ts b/src/evaluator.ts index f03ac667..6752cb26 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -1,7 +1,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 {ApiProblem, formatMessage} from './error'; import type {Location} from './sourceLocation'; import {getLength, getLocation} from './sourceLocation'; import type {Logger} from './logging'; @@ -56,14 +56,7 @@ export enum EvaluationErrorType { EVALUATION_FAILED = 'https://croct.help/api/evaluation/evaluation-failed', } -export type ErrorResponse = { - type: EvaluationErrorType, - title: string, - status: number, - detail?: string, -}; - -export class EvaluationError extends Error { +export class EvaluationError extends Error { public readonly response: T; public constructor(response: T) { @@ -80,7 +73,7 @@ type QueryErrorDetail = { location: Location, }; -export type QueryErrorResponse = ErrorResponse & { +export type QueryErrorResponse = ApiProblem & { errors: QueryErrorDetail[], }; @@ -173,7 +166,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.`, @@ -182,7 +175,7 @@ export class Evaluator { abortController.abort(); - this.logHelp(response.status); + this.logHelp(response); reject(new EvaluationError(response)); }, @@ -216,9 +209,9 @@ 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: @@ -308,8 +301,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); diff --git a/src/help.ts b/src/help.ts index 9ba5e725..132b7397 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,5 +1,31 @@ +import {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 | 402): string; + export function forApiProblem(problem: ApiProblem): string | undefined { + const forStatus = Help.forStatusCode(problem.status); + + if (forStatus !== undefined) { + return forStatus; + } + + switch (problem.type) { + case AuthErrorType.FORBIDDEN_ORIGIN: + return 'The request was not authorized, most likely due to invalid credentials. ' + + 'For help, see https://croct.help/sdk/javascript/invalid-credentials'; + 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 undefined; + } + } + + export function forStatusCode(statusCode: 204 | 401 | 408 | 402): string; export function forStatusCode(statusCode: number): string | undefined; @@ -13,14 +39,6 @@ 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 application has exceeded the monthly active users (MAU) quota. ' - + 'For help, see https://croct.help/sdk/javascript/mau-exceeded'; - - 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'; From a4c137693734f3d30a5fb07352b31db87f1ae46d Mon Sep 17 00:00:00 2001 From: Renan Date: Mon, 15 Jun 2026 16:54:16 -0300 Subject: [PATCH 4/6] WIP --- src/channel/httpBeaconChannel.ts | 3 +- src/contentFetcher.ts | 4 +- src/evaluator.ts | 3 +- src/help.ts | 8 +-- test/channel/httpBeaconChannel.test.ts | 8 ++- test/contentFetcher.test.ts | 78 ++++++++++++++++++++------ test/evaluator.test.ts | 31 +++++----- test/help.test.ts | 75 ++++++++++++++++++++++--- test/sdk.test.ts | 12 ++-- 9 files changed, 166 insertions(+), 56 deletions(-) diff --git a/src/channel/httpBeaconChannel.ts b/src/channel/httpBeaconChannel.ts index edf9e886..649847ef 100644 --- a/src/channel/httpBeaconChannel.ts +++ b/src/channel/httpBeaconChannel.ts @@ -4,7 +4,8 @@ import type {Envelope} from './guaranteedChannel'; import type {Logger} from '../logging'; import {NullLogger} from '../logging'; import type {CidAssigner} from '../cid'; -import {ApiProblem, formatMessage} from '../error'; +import {formatMessage} from '../error'; +import type {ApiProblem} from '../error'; import {CLIENT_LIBRARY} from '../constants'; import {Help} from '../help'; diff --git a/src/contentFetcher.ts b/src/contentFetcher.ts index dd54f971..db9f8dde 100644 --- a/src/contentFetcher.ts +++ b/src/contentFetcher.ts @@ -3,7 +3,8 @@ 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 {ApiProblem, formatMessage} from './error'; +import {formatMessage} from './error'; +import type {ApiProblem} from './error'; import type {Logger} from './logging'; import {NullLogger} from './logging'; import {ApiKey} from './apiKey'; @@ -56,7 +57,6 @@ export class InputError extends ContentError { } } - export type FetchResponseOptions = { includeSchema?: boolean, }; diff --git a/src/evaluator.ts b/src/evaluator.ts index 6752cb26..ff457792 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -1,7 +1,8 @@ import type {JsonObject, JsonValue} from '@croct/json'; import type {Token} from './token'; import {BASE_ENDPOINT_URL, CLIENT_LIBRARY, MAX_QUERY_LENGTH} from './constants'; -import {ApiProblem, formatMessage} from './error'; +import {formatMessage} from './error'; +import type {ApiProblem} from './error'; import type {Location} from './sourceLocation'; import {getLength, getLocation} from './sourceLocation'; import type {Logger} from './logging'; diff --git a/src/help.ts b/src/help.ts index 132b7397..5da67aea 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,4 +1,4 @@ -import {ApiProblem} from "./error"; +import type {ApiProblem} from './error'; enum AuthErrorType { FORBIDDEN_ORIGIN = 'https://croct.help/api/authentication/forbidden-origin', @@ -15,8 +15,8 @@ export namespace Help { switch (problem.type) { case AuthErrorType.FORBIDDEN_ORIGIN: - return 'The request was not authorized, most likely due to invalid credentials. ' - + 'For help, see https://croct.help/sdk/javascript/invalid-credentials'; + 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'; @@ -25,7 +25,7 @@ export namespace Help { } } - export function forStatusCode(statusCode: 204 | 401 | 408 | 402): string; + export function forStatusCode(statusCode: 202 | 401 | 408): string; export function forStatusCode(statusCode: number): string | undefined; diff --git a/test/channel/httpBeaconChannel.test.ts b/test/channel/httpBeaconChannel.test.ts index 79a9a7d0..c8d3341d 100644 --- a/test/channel/httpBeaconChannel.test.ts +++ b/test/channel/httpBeaconChannel.test.ts @@ -188,6 +188,7 @@ describe('An HTTP beacon channel', () => { type NonRetryableErrorScenario = { status: number, + type?: string, title: string, log?: string, }; @@ -209,22 +210,25 @@ describe('An HTTP beacon channel', () => { }, { status: 403, + type: 'https://croct.help/api/authentication/quota-exceeded', 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, }), diff --git a/test/contentFetcher.test.ts b/test/contentFetcher.test.ts index ee34d955..9529c50c 100644 --- a/test/contentFetcher.test.ts +++ b/test/contentFetcher.test.ts @@ -2,8 +2,9 @@ import type {CallLog, UserRouteConfig} from 'fetch-mock'; import fetchMock from 'fetch-mock'; import type {EvaluationContext} from '../src/evaluator'; import {Token} from '../src/token'; -import type {ErrorResponse, FetchOptions, FetchResponse} from '../src/contentFetcher'; -import {ContentFetcher, ContentError, ContentErrorType} from '../src/contentFetcher'; +import type {FetchOptions, FetchResponse, InputErrorResponse} from '../src/contentFetcher'; +import {ContentFetcher, ContentError, ContentErrorType, InputError} from '../src/contentFetcher'; +import type {ApiProblem} from '../src/error'; import {BASE_ENDPOINT_URL, CLIENT_LIBRARY} from '../src/constants'; import {ApiKey} from '../src/apiKey'; import type {Logger} from '../src/logging'; @@ -644,7 +645,7 @@ describe('A content fetcher', () => { appId: appId, }); - const response: ErrorResponse = { + const response: ApiProblem = { type: 'error', title: 'Error title', status: 400, @@ -664,14 +665,49 @@ describe('A content fetcher', () => { await expect(promise).rejects.toHaveProperty('response', response); }); + it('should report an input error if the request payload is invalid', async () => { + const fetcher = new ContentFetcher({ + appId: appId, + }); + + const response: InputErrorResponse = { + type: ContentErrorType.INVALID_PAYLOAD, + title: 'The request payload is invalid.', + status: 422, + violations: [ + { + path: 'slotId', + reason: 'The slot ID is invalid.', + details: { + pattern: '^[a-z-]+$', + }, + }, + ], + }; + + fetchMock.mockGlobal().route({ + ...requestMatcher, + response: { + status: response.status, + body: response, + }, + }); + + const promise = fetcher.fetch(slotId); + + await expect(promise).rejects.toThrow(InputError); + await expect(promise).rejects.toBeInstanceOf(ContentError); + await expect(promise).rejects.toHaveProperty('response', response); + }); + it('should catch deserialization errors', async () => { const fetcher = new ContentFetcher({ appId: appId, }); - const response: ErrorResponse = { + const response: ApiProblem = { title: 'Error 500 - Internal Server Error', - type: ContentErrorType.UNEXPECTED_ERROR, + type: ContentErrorType.INTERNAL_ERROR, detail: 'Please try again or contact Croct support if the error persists.', status: 500, }; @@ -702,9 +738,9 @@ describe('A content fetcher', () => { appId: appId, }); - const response: ErrorResponse = { + const response: ApiProblem = { title: 'Unknown error', - type: ContentErrorType.UNEXPECTED_ERROR, + type: ContentErrorType.INTERNAL_ERROR, detail: 'Please try again or contact Croct support if the error persists.', status: 500, }; @@ -720,9 +756,9 @@ describe('A content fetcher', () => { appId: appId, }); - const response: ErrorResponse = { + const response: ApiProblem = { title: 'Something went wrong', - type: ContentErrorType.UNEXPECTED_ERROR, + type: ContentErrorType.INTERNAL_ERROR, detail: 'Please try again or contact Croct support if the error persists.', status: 500, }; @@ -742,23 +778,27 @@ describe('A content fetcher', () => { type HelpScenario = { status: number, + type: string, title: string, }; it.each([ { status: 401, + type: 'https://croct.help/api/content/some-error', title: 'Unauthorized request', }, { status: 403, + type: 'https://croct.help/api/authentication/quota-exceeded', title: 'Quota exceeded', }, { status: 403, + type: 'https://croct.help/api/authentication/forbidden-origin', title: 'Unallowed origin', }, - ])('should log help messages for status code $status', async scenario => { + ])('should log help messages for the API problem $type', async scenario => { const logger: Logger = { debug: jest.fn(), info: jest.fn(), @@ -771,15 +811,17 @@ describe('A content fetcher', () => { logger: logger, }); + const response: ApiProblem = { + type: scenario.type, + status: scenario.status, + title: scenario.title, + }; + fetchMock.mockGlobal().route({ ...requestMatcher, response: { status: scenario.status, - body: { - type: 'https://croct.help/api/content', - status: scenario.status, - title: scenario.title, - } satisfies ErrorResponse, + body: response, }, }); @@ -787,7 +829,7 @@ describe('A content fetcher', () => { await expect(promise).rejects.toThrowWithMessage(ContentError, scenario.title); - const log = Help.forStatusCode(scenario.status); + const log = Help.forApiProblem(response); expect(log).toBeDefined(); expect(logger.error).toHaveBeenCalledWith(log); @@ -837,8 +879,8 @@ describe('A content fetcher', () => { describe('A content error', () => { it('should have a response', () => { - const response: ErrorResponse = { - type: ContentErrorType.UNEXPECTED_ERROR, + const response: ApiProblem = { + type: ContentErrorType.INTERNAL_ERROR, title: 'Error title', status: 400, }; diff --git a/test/evaluator.test.ts b/test/evaluator.test.ts index d1502e9c..72940dbe 100644 --- a/test/evaluator.test.ts +++ b/test/evaluator.test.ts @@ -1,7 +1,8 @@ import type {CallLog, UserRouteConfig} from 'fetch-mock'; import fetchMock from 'fetch-mock'; -import type {ErrorResponse, EvaluationContext, EvaluationOptions, QueryErrorResponse} from '../src/evaluator'; +import type {EvaluationContext, EvaluationOptions, QueryErrorResponse} from '../src/evaluator'; import {EvaluationError, EvaluationErrorType, Evaluator, QueryError} from '../src/evaluator'; +import type {ApiProblem} from '../src/error'; import {Token} from '../src/token'; import {BASE_ENDPOINT_URL, CLIENT_LIBRARY} from '../src/constants'; import {ApiKey} from '../src/apiKey'; @@ -429,8 +430,8 @@ describe('An evaluator', () => { appId: appId, }); - const response: ErrorResponse = { - type: EvaluationErrorType.UNALLOWED_RESULT, + const response: ApiProblem = { + type: EvaluationErrorType.INTERNAL_ERROR, title: 'Error title', status: 400, }; @@ -448,11 +449,9 @@ describe('An evaluator', () => { await expect(promise).rejects.toThrow(EvaluationError); await expect(promise).rejects.toHaveProperty('response', response); }); - it.each([ [EvaluationErrorType.EVALUATION_FAILED], [EvaluationErrorType.INVALID_QUERY], - [EvaluationErrorType.TOO_COMPLEX_QUERY], ])( 'should report an query error if the error that can be traced back to the offending input (%s)', async (errorType: EvaluationErrorType) => { @@ -536,7 +535,7 @@ describe('An evaluator', () => { appId: appId, }); - const response: ErrorResponse = { + const response: ApiProblem = { title: 'Error 500 - Internal Server Error', type: EvaluationErrorType.INTERNAL_ERROR, detail: 'Please try again or contact Croct support if the error persists.', @@ -562,7 +561,7 @@ describe('An evaluator', () => { appId: appId, }); - const response: ErrorResponse = { + const response: ApiProblem = { title: 'Unknown error', type: EvaluationErrorType.INTERNAL_ERROR, detail: 'Please try again or contact Croct support if the error persists.', @@ -587,7 +586,7 @@ describe('An evaluator', () => { appId: appId, }); - const response: ErrorResponse = { + const response: ApiProblem = { title: 'Network error.', type: EvaluationErrorType.INTERNAL_ERROR, detail: 'Please try again or contact Croct support if the error persists.', @@ -609,23 +608,27 @@ describe('An evaluator', () => { type HelpScenario = { status: number, + type: string, title: string, }; it.each([ { status: 401, + type: 'https://croct.help/api/evaluation/some-error', title: 'Unauthorized request', }, { status: 403, + type: 'https://croct.help/api/authentication/quota-exceeded', title: 'Quota exceeded', }, { status: 403, + type: 'https://croct.help/api/authentication/forbidden-origin', title: 'Unallowed origin', }, - ])('should log help messages for status code $status', async scenario => { + ])('should log help messages for the API problem $type', async scenario => { const logger: Logger = { debug: jest.fn(), info: jest.fn(), @@ -638,9 +641,9 @@ describe('An evaluator', () => { logger: logger, }); - const response: ErrorResponse = { + const response: ApiProblem = { title: scenario.title, - type: EvaluationErrorType.INTERNAL_ERROR, + type: scenario.type, status: scenario.status, }; @@ -656,7 +659,7 @@ describe('An evaluator', () => { await expect(promise).rejects.toThrowWithMessage(EvaluationError, scenario.title); - const help = Help.forStatusCode(scenario.status); + const help = Help.forApiProblem(response); expect(help).toBeDefined(); @@ -708,8 +711,8 @@ describe('An evaluator', () => { describe('An evaluation error', () => { it('should have a response', () => { - const response: ErrorResponse = { - type: EvaluationErrorType.UNALLOWED_RESULT, + const response: ApiProblem = { + type: EvaluationErrorType.INTERNAL_ERROR, title: 'Error title', status: 400, }; diff --git a/test/help.test.ts b/test/help.test.ts index 5d51493c..5b97220c 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -1,3 +1,4 @@ +import type {ApiProblem} from '../src/error'; import {Help} from '../src/help'; describe('A function to provide help for errors', () => { @@ -15,14 +16,6 @@ describe('A function to provide help for errors', () => { status: 401, help: 'https://croct.help/sdk/javascript/invalid-credentials', }, - { - status: 403, - help: 'https://croct.help/sdk/javascript/mau-exceeded', - }, - { - status: 403, - help: 'https://croct.help/sdk/javascript/unauthorized-origin', - }, { status: 408, help: 'https://croct.help/sdk/javascript/request-timeout', @@ -34,4 +27,70 @@ describe('A function to provide help for errors', () => { it('should return undefined for status codes without help', () => { expect(Help.forStatusCode(999)).toBeUndefined(); }); + + type ApiProblemScenario = { + problem: ApiProblem, + help: string, + }; + + it.each([ + { + problem: { + type: 'https://croct.help/api/some-error', + title: 'Service is suspended.', + status: 202, + }, + help: 'https://croct.help/sdk/javascript/suspended-service', + }, + { + problem: { + type: 'https://croct.help/api/some-error', + title: 'Unauthorized request.', + status: 401, + }, + help: 'https://croct.help/sdk/javascript/invalid-credentials', + }, + { + problem: { + type: 'https://croct.help/api/some-error', + title: 'Request timed out.', + status: 408, + }, + help: 'https://croct.help/sdk/javascript/request-timeout', + }, + { + problem: { + type: 'https://croct.help/api/authentication/forbidden-origin', + title: 'Unallowed origin.', + status: 403, + }, + help: 'https://croct.help/sdk/javascript/unauthorized-origin', + }, + { + problem: { + type: 'https://croct.help/api/authentication/quota-exceeded', + title: 'Quota exceeded.', + status: 403, + }, + help: 'https://croct.help/sdk/javascript/mau-exceeded', + }, + ])('should provide help for the API problem $problem.type', scenario => { + expect(Help.forApiProblem(scenario.problem)).toContain(scenario.help); + }); + + it('should prioritize status code help', () => { + expect(Help.forApiProblem({ + type: 'https://croct.help/api/authentication/quota-exceeded', + title: 'Unauthorized request.', + status: 401, + })).toContain('https://croct.help/sdk/javascript/invalid-credentials'); + }); + + it('should return undefined for API problems without help', () => { + expect(Help.forApiProblem({ + type: 'https://croct.help/api/some-error', + title: 'Some error.', + status: 500, + })).toBeUndefined(); + }); }); diff --git a/test/sdk.test.ts b/test/sdk.test.ts index bc3fd6fe..b89630ac 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -7,10 +7,10 @@ import {NullLogger} from '../src/logging'; import {Token} from '../src/token'; import {TabEventEmulator} from './utils/tabEventEmulator'; import type {BeaconPayload, NothingChanged} from '../src/trackingEvents'; -import type {ErrorResponse as ContentFetchErrorResponse, FetchResponse} from '../src/contentFetcher'; +import type {FetchResponse} from '../src/contentFetcher'; import {ContentError} from '../src/contentFetcher'; +import type {ApiProblem} from '../src/error'; import {BASE_ENDPOINT_URL} from '../src/constants'; -import type {ErrorResponse as EvaluationErrorResponse} from '../src/evaluator'; import {EvaluationError} from '../src/evaluator'; import {Container} from '../src/container'; @@ -496,7 +496,7 @@ describe('A SDK', () => { await expect(promise).rejects.toHaveProperty('response', expect.objectContaining({ detail: 'The evaluation took more than 5ms to complete.', - } satisfies Partial)); + } satisfies Partial)); }); it('should configure the evaluator with the default timeout', async () => { @@ -533,7 +533,7 @@ describe('A SDK', () => { await expect(promise).rejects.toHaveProperty('response', expect.objectContaining({ detail: 'The evaluation took more than 5000ms to complete.', - } satisfies Partial)); + } satisfies Partial)); }); it('should configure the content fetch with the default preferred locale', async () => { @@ -614,7 +614,7 @@ describe('A SDK', () => { await expect(promise).rejects.toHaveProperty('response', expect.objectContaining({ detail: 'The content took more than 5ms to load.', - } satisfies Partial)); + } satisfies Partial)); }); it('should configure the content fetcher with the default timeout', async () => { @@ -659,7 +659,7 @@ describe('A SDK', () => { await expect(promise).rejects.toHaveProperty('response', expect.objectContaining({ detail: 'The content took more than 5000ms to load.', - } satisfies Partial)); + } satisfies Partial)); }); it.each([ From 2711e624e5971579139f02e806434e8a56bb18cd Mon Sep 17 00:00:00 2001 From: Renan Date: Wed, 17 Jun 2026 14:55:04 -0300 Subject: [PATCH 5/6] Update internal error URLs to link the server errors --- src/channel/httpBeaconChannel.ts | 3 ++- src/contentFetcher.ts | 2 +- src/evaluator.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/channel/httpBeaconChannel.ts b/src/channel/httpBeaconChannel.ts index 649847ef..6bd95fb7 100644 --- a/src/channel/httpBeaconChannel.ts +++ b/src/channel/httpBeaconChannel.ts @@ -17,8 +17,9 @@ export type Configuration = { }; export enum TrackingErrorType { - INTERNAL_ERROR = 'https://croct.help/sdk/javascript/internal-error', 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> { diff --git a/src/contentFetcher.ts b/src/contentFetcher.ts index db9f8dde..dab7f808 100644 --- a/src/contentFetcher.ts +++ b/src/contentFetcher.ts @@ -12,9 +12,9 @@ import {Help} from './help'; export enum ContentErrorType { TIMEOUT = 'https://croct.help/sdk/javascript/request-timeout', - INTERNAL_ERROR = 'https://croct.help/sdk/javascript/internal-error', SUSPENDED_SERVICE = 'https://croct.help/sdk/javascript/suspended-service', // Server errors + INTERNAL_ERROR = 'https://croct.help/api/content/internal-error', INVALID_PAYLOAD = 'https://croct.help/api/content/invalid-payload', } diff --git a/src/evaluator.ts b/src/evaluator.ts index ff457792..bd07d585 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -49,10 +49,10 @@ export type EvaluationOptions = { export enum EvaluationErrorType { TIMEOUT = 'https://croct.help/sdk/javascript/request-timeout', - INTERNAL_ERROR = 'https://croct.help/sdk/javascript/internal-error', SUSPENDED_SERVICE = 'https://croct.help/sdk/javascript/suspended-service', 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', } From 7cd8e721cfa723dccbc9c7b57df0309f127ed73c Mon Sep 17 00:00:00 2001 From: Renan Date: Thu, 18 Jun 2026 17:42:47 -0300 Subject: [PATCH 6/6] Apply requested changes --- src/help.ts | 8 +------- test/help.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/help.ts b/src/help.ts index 5da67aea..24599d73 100644 --- a/src/help.ts +++ b/src/help.ts @@ -7,12 +7,6 @@ enum AuthErrorType { export namespace Help { export function forApiProblem(problem: ApiProblem): string | undefined { - const forStatus = Help.forStatusCode(problem.status); - - if (forStatus !== undefined) { - return forStatus; - } - switch (problem.type) { case AuthErrorType.FORBIDDEN_ORIGIN: return 'The origin of the request is not allowed in your application settings. ' @@ -21,7 +15,7 @@ export namespace Help { return 'The application has exceeded the monthly active users (MAU) quota. ' + 'For help, see https://croct.help/sdk/javascript/mau-exceeded'; default: - return undefined; + return Help.forStatusCode(problem.status); } } diff --git a/test/help.test.ts b/test/help.test.ts index 5b97220c..a03351b1 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -78,12 +78,12 @@ describe('A function to provide help for errors', () => { expect(Help.forApiProblem(scenario.problem)).toContain(scenario.help); }); - it('should prioritize status code help', () => { + it('should prioritize the error type help', () => { expect(Help.forApiProblem({ type: 'https://croct.help/api/authentication/quota-exceeded', title: 'Unauthorized request.', status: 401, - })).toContain('https://croct.help/sdk/javascript/invalid-credentials'); + })).toContain('https://croct.help/sdk/javascript/mau-exceeded'); }); it('should return undefined for API problems without help', () => {