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
4 changes: 2 additions & 2 deletions nodes/UpRockCrawler/UpRockCrawler.node.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"node": "@uprock-ai/n8n-nodes-uprock",
"node": "@uprock-ai/n8n-nodes-uprock.upRockCrawler",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Developer Tools"],
"categories": ["Development"],
"resources": {
"credentialDocumentation": [
{
Expand Down
69 changes: 69 additions & 0 deletions nodes/UpRockCrawler/UpRockCrawler.node.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ApplicationError,
NodeConnectionTypes,
NodeApiError,
NodeOperationError,
type ICredentialDataDecryptedObject,
type ICredentialsDecrypted,
Expand All @@ -11,6 +12,7 @@ import {
type INodeCredentialTestResult,
type INodeType,
type INodeTypeDescription,
type JsonObject,
} from 'n8n-workflow';
import { commandDescription } from './commands';
import {
Expand Down Expand Up @@ -134,6 +136,69 @@ function isDataObject(value: unknown): value is IDataObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function hasHttpStatus(value: IDataObject | undefined): boolean {
const status = value?.status ?? value?.statusCode ?? value?.httpCode;

return typeof status === 'number' || typeof status === 'string';
}

function isHttpError(error: unknown): error is JsonObject {
if (!isDataObject(error)) {
return false;
}

const response = isDataObject(error.response) ? error.response : undefined;
const options = isDataObject(error.options)
? error.options
: isDataObject(error.config)
? error.config
: undefined;

return (
hasHttpStatus(error) ||
hasHttpStatus(response) ||
response?.body !== undefined ||
response?.data !== undefined ||
response?.headers !== undefined ||
typeof options?.url === 'string' ||
typeof error.url === 'string'
);
}

function toNodeApiErrorResponse(error: JsonObject): JsonObject {
if (!(error instanceof Error)) {
return error;
}

const errorDetails = error as Error & Record<string, unknown>;
const payload: Record<string, unknown> = {
name: error.name,
message: error.message,
};

for (const key of ['status', 'statusCode', 'httpCode', 'code', 'response', 'error'] as const) {
if (errorDetails[key] !== undefined) {
payload[key] = errorDetails[key];
}
}

const requestOptions = isDataObject(errorDetails.options)
? errorDetails.options
: isDataObject(errorDetails.config)
? errorDetails.config
: undefined;

if (requestOptions) {
payload.options = {
url: requestOptions.url,
method: requestOptions.method,
headers: requestOptions.headers,
};
}

return payload as JsonObject;
}

function getMcpDebug(error: unknown): IDataObject | undefined {
if (!(error instanceof Error)) {
return undefined;
Expand Down Expand Up @@ -497,6 +562,10 @@ export class UpRockCrawler implements INodeType {
const errorMessage = formatErrorWithMcpDebug(getErrorMessage(error), mcpDebug);

if (!this.continueOnFail()) {
if (isHttpError(error)) {
throw new NodeApiError(this.getNode(), toNodeApiErrorResponse(error), { itemIndex });
}

throw new NodeOperationError(
this.getNode(),
error instanceof Error ? errorMessage : getErrorMessage(error),
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.5.5",
"description": "n8n community node for crawling URLs, fetching rendered content, running regional sweeps, and researching the web through UpRock.",
"license": "MIT",
"homepage": "",
"homepage": "https://github.com/uprockcom/n8n-nodes-uprock#readme",
"keywords": [
"n8n-community-node-package"
],
Expand Down
53 changes: 53 additions & 0 deletions tests/uprock-crawler-node.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ function createToolCallErrorResponse(message, data = undefined) {
};
}

function createHttpError(message, response) {
const error = new Error(message);
error.response = response;
error.statusCode = response.statusCode;
error.options = {
url: 'https://mcp.test/mcp',
};
return error;
}

function createExecuteContext({
parameterItems,
responses,
Expand Down Expand Up @@ -428,6 +438,49 @@ test('execute surfaces MCP debug details in the thrown error when a sweep fails'
);
});

test('execute wraps HTTP request failures in NodeApiError', async () => {
const node = new UpRockCrawler();
const { context } = createExecuteContext({
parameterItems: [
{
command: 'web_research',
query: 'latest n8n news',
country: 'US',
maxSources: 3,
},
],
responses: [
createHttpError('Request failed with status code 429', {
statusCode: 429,
headers: {
'retry-after': '30',
},
body: {
error: 'rate_limited',
message: 'Too many requests',
},
}),
],
});

await assert.rejects(
() => node.execute.call(context),
(error) => {
assert.equal(error.constructor.name, 'NodeApiError');
assert.equal(error.name, 'NodeApiError');
assert.equal(error.httpCode, '429');
assert.equal(error.context.itemIndex, 0);
assert.equal(error.errorResponse.options.url, 'https://mcp.test/mcp');
assert.equal(error.errorResponse.response.headers['retry-after'], '30');
assert.deepEqual(error.errorResponse.response.body, {
error: 'rate_limited',
message: 'Too many requests',
});
return true;
},
);
});

test('execute runs fetch and hydrates markdown and html resources', async () => {
const node = new UpRockCrawler();
const { context, requests } = createExecuteContext({
Expand Down
Loading