Skip to content
Open
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
71 changes: 71 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { inspectValue } from '@crawlee/utils';
import type { z } from 'zod';

/**
* Errors of `NonRetryableError` type will never be retried by the crawler.
Expand Down Expand Up @@ -90,3 +91,73 @@ export class ServiceConflictError extends Error {
* Subclasses can catch this error to skip their own navigation-dependent logic.
*/
export class NavigationSkippedError extends NonRetryableError {}

/** Formats a zod issue path like `groups[0]` or `countryCode`. */
function formatIssuePath(path: readonly PropertyKey[]): string {
let out = '';
for (const key of path) {
if (typeof key === 'number') out += `[${key}]`;
else out += out ? `.${String(key)}` : String(key);
}
return out;
}

/** Reads the value at `path` from the validated input, to include in the error. */
function valueAtPath(root: unknown, path: readonly PropertyKey[]): unknown {
let current = root;
for (const key of path) {
if (current === null || typeof current !== 'object') return undefined;
current = (current as Record<PropertyKey, unknown>)[key];
}
return current;
}

/** Renders a primitive received value for an error; skips objects/Dates (noisy). */
function describeReceived(value: unknown): string | undefined {
switch (typeof value) {
case 'string':
return value;
case 'number':
case 'boolean':
case 'bigint':
return String(value);
default:
return undefined;
}
}

/**
* Formats a `ZodError` as a plain, human-readable message that names the
* offending field *and* the value it received (e.g. ``must match pattern
* /^[A-Z]{2}$/ at `countryCode`, got `CZE` ``) — closer to the old `ow` errors
* than zod's default, which omits the received value.
*/
function formatZodError(error: z.ZodError, root: unknown): string {
return error.issues
.map((issue) => {
const location = issue.path.length ? ` at \`${formatIssuePath(issue.path)}\`` : '';
const received = describeReceived(valueAtPath(root, issue.path));
const got = received === undefined ? '' : `, got \`${received}\``;
return `${issue.message}${location}${got}`;
})
.join('\n');
}

/**
* Thrown when an argument fails schema validation.
*
* Its `message` is a human-readable sentence naming the offending field and the
* value it received (rather than a raw JSON dump). The structured
* {@link https://zod.dev | zod} issues are available on `issues`, and the
* original `ZodError` on `cause`, for programmatic inspection.
*/
export class ArgumentValidationError extends Error {
/** Structured issues from the underlying schema check. */
readonly issues: z.ZodError['issues'];

constructor(error: z.ZodError, value: unknown) {
super(formatZodError(error, value), { cause: error });
this.name = 'ArgumentValidationError';
this.issues = error.issues;
}
}
32 changes: 32 additions & 0 deletions test/core/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ArgumentValidationError } from '@crawlee/core';
import { describe, expect, test } from 'vitest';
import { z } from 'zod';

describe('ArgumentValidationError', () => {
const schema = z
.object({
countryCode: z.string().regex(/^[A-Z]{2}$/),
retries: z.number().optional(),
})
.strict();

test('message names the offending field and the value it received', () => {
const error = new ArgumentValidationError(schema.safeParse({ countryCode: 'CZE' }).error!, {
countryCode: 'CZE',
});

expect(error).toBeInstanceOf(Error);
expect(error.name).toBe('ArgumentValidationError');
expect(error.message).toBe('Invalid string: must match pattern /^[A-Z]{2}$/ at `countryCode`, got `CZE`');
});

test('exposes structured issues and keeps the ZodError as cause', () => {
const zodError = schema.safeParse({ retries: 'lots' }).error!;
const error = new ArgumentValidationError(zodError, { retries: 'lots' });

// `issues` is reachable directly, without digging into `cause`.
expect(error.issues).toBe(zodError.issues);
expect(error.issues.map((issue) => issue.path)).toContainEqual(['retries']);
expect(error.cause).toBe(zodError);
});
});
Loading