Skip to content

Commit 9d01334

Browse files
B4nanclaude
andauthored
fix(core): ensure maxCrawlDepth warning is logged only once (#3337)
## Summary - Refactored enqueueLinks limit logging to distinguish between two scenarios: - **`enqueueLinks({ limit: X })`**: Now logs once per request handler call (not once per run), providing feedback each time the limit is hit in different handlers - **`maxCrawlDepth`**: Logs once per crawler run, avoiding spam when multiple handlers trigger the depth limit - Replaced multiple boolean flags with a cleaner `Set<string>` pattern for "log once per run" messages - Added comprehensive tests for both logging behaviors ## Test plan - [x] Existing tests pass - [x] New test: `enqueueLinks limit log message should be logged once per request handler, not once per run` - [x] New test: `maxCrawlDepth limit log message should only be logged once per run` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Closes #3336 --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c292d3e commit 9d01334

2 files changed

Lines changed: 169 additions & 29 deletions

File tree

packages/basic-crawler/src/internals/basic-crawler.ts

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -563,8 +563,7 @@ export class BasicCrawler<Context extends CrawlingContext = BasicCrawlingContext
563563
protected respectRobotsTxtFile: boolean | { userAgent?: string };
564564
protected onSkippedRequest?: SkippedRequestCallback;
565565
private _closeEvents?: boolean;
566-
private shouldLogMaxEnqueuedRequestsExceeded = true;
567-
private shouldLogShuttingDown = true;
566+
private loggedPerRun = new Set<string>();
568567
private experiments: CrawlerExperiments;
569568
private readonly robotsTxtFileCache: LruCache<RobotsTxtFile>;
570569
private _experimentWarnings: Partial<Record<keyof CrawlerExperiments, boolean>> = {};
@@ -811,24 +810,20 @@ export class BasicCrawler<Context extends CrawlingContext = BasicCrawlingContext
811810
runTaskFunction: this._runTaskFunction.bind(this),
812811
isTaskReadyFunction: async () => {
813812
if (isMaxPagesExceeded()) {
814-
if (this.shouldLogShuttingDown) {
815-
log.info(
816-
'Crawler reached the maxRequestsPerCrawl limit of ' +
817-
`${this.maxRequestsPerCrawl} requests and will shut down soon. Requests that are in progress will be allowed to finish.`,
818-
);
819-
this.shouldLogShuttingDown = false;
820-
}
813+
this.logOncePerRun(
814+
'shuttingDown',
815+
'Crawler reached the maxRequestsPerCrawl limit of ' +
816+
`${this.maxRequestsPerCrawl} requests and will shut down soon. Requests that are in progress will be allowed to finish.`,
817+
);
821818
return false;
822819
}
823820

824821
if (this.unexpectedStop) {
825-
if (this.shouldLogShuttingDown) {
826-
this.log.info(
827-
'No new requests are allowed because the `stop()` method has been called. ' +
828-
'Ongoing requests will be allowed to complete.',
829-
);
830-
this.shouldLogShuttingDown = false;
831-
}
822+
this.logOncePerRun(
823+
'shuttingDown',
824+
'No new requests are allowed because the `stop()` method has been called. ' +
825+
'Ongoing requests will be allowed to complete.',
826+
);
832827
return false;
833828
}
834829

@@ -841,15 +836,13 @@ export class BasicCrawler<Context extends CrawlingContext = BasicCrawlingContext
841836
'and all requests that were in progress at that time have now finished. ' +
842837
`In total, the crawler processed ${this.handledRequestsCount} requests and will shut down.`,
843838
);
844-
this.shouldLogShuttingDown = false;
845839
return true;
846840
}
847841

848842
if (this.unexpectedStop) {
849843
this.log.info(
850844
'The crawler has finished all the remaining ongoing requests and will shut down now.',
851845
);
852-
this.shouldLogShuttingDown = false;
853846
return true;
854847
}
855848

@@ -1000,8 +993,7 @@ export class BasicCrawler<Context extends CrawlingContext = BasicCrawlingContext
1000993

1001994
this.unexpectedStop = false;
1002995
this.running = true;
1003-
this.shouldLogMaxEnqueuedRequestsExceeded = true;
1004-
this.shouldLogShuttingDown = true;
996+
this.loggedPerRun.clear();
1005997

1006998
await purgeDefaultStorages({
1007999
onlyPurgeOnce: true,
@@ -1154,24 +1146,31 @@ export class BasicCrawler<Context extends CrawlingContext = BasicCrawlingContext
11541146
}
11551147

11561148
protected async handleSkippedRequest(options: Parameters<SkippedRequestCallback>[0]): Promise<void> {
1157-
if (options.reason === 'limit' && this.shouldLogMaxEnqueuedRequestsExceeded) {
1158-
this.log.info(
1149+
if (options.reason === 'limit') {
1150+
this.logOncePerRun(
1151+
'maxRequestsPerCrawl',
11591152
'The number of requests enqueued by the crawler reached the maxRequestsPerCrawl limit of ' +
11601153
`${this.maxRequestsPerCrawl} requests and no further requests will be added.`,
11611154
);
1162-
this.shouldLogMaxEnqueuedRequestsExceeded = false;
11631155
}
11641156

1165-
if (options.reason === 'enqueueLimit') {
1166-
const enqueuedRequestLimit = this.calculateEnqueuedRequestLimit();
1167-
if (enqueuedRequestLimit === undefined || enqueuedRequestLimit !== 0) {
1168-
this.log.info('The number of requests enqueued by the crawler reached the enqueueLinks limit.');
1169-
}
1157+
if (options.reason === 'depth') {
1158+
this.logOncePerRun(
1159+
'maxCrawlDepth',
1160+
`The crawler reached the maxCrawlDepth limit of ${this.maxCrawlDepth} and no further requests will be enqueued.`,
1161+
);
11701162
}
11711163

11721164
await this.onSkippedRequest?.(options);
11731165
}
11741166

1167+
private logOncePerRun(key: string, message: string): void {
1168+
if (!this.loggedPerRun.has(key)) {
1169+
this.log.info(message);
1170+
this.loggedPerRun.add(key);
1171+
}
1172+
}
1173+
11751174
/**
11761175
* Adds requests to the queue in batches. By default, it will resolve after the initial batch is added, and continue
11771176
* adding the rest in background. You can configure the batch size via `batchSize` option and the sleep time in between
@@ -1712,10 +1711,26 @@ export class BasicCrawler<Context extends CrawlingContext = BasicCrawlingContext
17121711
return options.transformRequestFunction?.(newRequest) ?? newRequest;
17131712
};
17141713

1714+
// Create a request-scoped callback that logs enqueueLimit once per request handler call
1715+
// Only log if an explicit limit was passed to enqueueLinks (not the internal maxRequestsPerCrawl-derived limit)
1716+
let loggedEnqueueLimitForThisRequest = false;
1717+
const onSkippedRequest: SkippedRequestCallback = async (skippedOptions) => {
1718+
if (skippedOptions.reason === 'enqueueLimit') {
1719+
if (!loggedEnqueueLimitForThisRequest && options.limit !== undefined) {
1720+
this.log.info(
1721+
`Skipping URLs in the handler for ${request.url} due to the enqueueLinks limit of ${options.limit}.`,
1722+
);
1723+
loggedEnqueueLimitForThisRequest = true;
1724+
}
1725+
}
1726+
1727+
await this.handleSkippedRequest(skippedOptions);
1728+
};
1729+
17151730
return enqueueLinks({
17161731
requestQueue,
17171732
robotsTxtFile: await this.getRobotsTxtFileForUrl(request!.url),
1718-
onSkippedRequest: this.handleSkippedRequest,
1733+
onSkippedRequest,
17191734
limit: this.calculateEnqueuedRequestLimit(options.limit),
17201735

17211736
// Allow user options to override defaults set above ⤴

test/core/crawlers/basic_crawler.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1809,6 +1809,131 @@ describe('BasicCrawler', () => {
18091809
// Should only have added the first 2 requests (since 2 were already processed and 1 is in progress, limit allows 2 more)
18101810
expect(addRequestsBatchedSpy).toHaveBeenCalledTimes(2);
18111811
});
1812+
1813+
test('enqueueLinks limit log message should only be logged once', async () => {
1814+
const requestQueue = await RequestQueue.open();
1815+
1816+
// Will try to add 10 requests with a limit of 2
1817+
const requestsToAdd = Array.from({ length: 10 }, (_, i) => `http://example.com/${i}`);
1818+
1819+
const crawler = new BasicCrawler({
1820+
requestQueue,
1821+
requestHandler: async (context) => {
1822+
if (context.request.label) {
1823+
return;
1824+
}
1825+
1826+
await context.enqueueLinks({ urls: requestsToAdd, limit: 2, label: 'child' });
1827+
},
1828+
});
1829+
1830+
const infoSpy = vitest.spyOn(crawler.log, 'info');
1831+
1832+
await crawler.run(['http://example.com']);
1833+
1834+
// The enqueueLinks limit message should only appear once, not 8 times (for each skipped request)
1835+
const enqueueLimitMessages = infoSpy.mock.calls.filter(
1836+
(call) => typeof call[0] === 'string' && call[0].includes('enqueueLinks limit'),
1837+
);
1838+
expect(enqueueLimitMessages).toHaveLength(1);
1839+
});
1840+
1841+
test('enqueueLinks limit log message should be logged again on subsequent runs', async () => {
1842+
const requestQueue = await RequestQueue.open();
1843+
1844+
const requestsToAdd = Array.from({ length: 5 }, (_, i) => `http://example.com/${i}`);
1845+
1846+
const crawler = new BasicCrawler({
1847+
requestQueue,
1848+
requestHandler: async (context) => {
1849+
if (context.request.label) {
1850+
return;
1851+
}
1852+
1853+
await context.enqueueLinks({ urls: requestsToAdd, limit: 1, label: 'child' });
1854+
},
1855+
});
1856+
1857+
const infoSpy = vitest.spyOn(crawler.log, 'info');
1858+
1859+
// First run
1860+
await crawler.run(['http://example.com/first']);
1861+
1862+
// Second run with a new start URL
1863+
await crawler.run(['http://example.com/second']);
1864+
1865+
// The enqueueLinks limit message should appear twice (once per run)
1866+
const enqueueLimitMessages = infoSpy.mock.calls.filter(
1867+
(call) => typeof call[0] === 'string' && call[0].includes('enqueueLinks limit'),
1868+
);
1869+
expect(enqueueLimitMessages).toHaveLength(2);
1870+
});
1871+
1872+
test('enqueueLinks limit log message should be logged once per request handler, not once per run', async () => {
1873+
const requestQueue = await RequestQueue.open();
1874+
1875+
// Each handler will try to add 5 URLs with a limit of 1
1876+
const requestsToAdd = Array.from({ length: 5 }, (_, i) => `http://example.com/child${i}`);
1877+
1878+
const crawler = new BasicCrawler({
1879+
requestQueue,
1880+
requestHandler: async (context) => {
1881+
if (context.request.label === 'child') {
1882+
return;
1883+
}
1884+
1885+
await context.enqueueLinks({ urls: requestsToAdd, limit: 1, label: 'child' });
1886+
},
1887+
});
1888+
1889+
const infoSpy = vitest.spyOn(crawler.log, 'info');
1890+
1891+
// Single run with two initial requests - both will trigger the limit
1892+
await crawler.run(['http://example.com/first', 'http://example.com/second']);
1893+
1894+
// The enqueueLinks limit message should appear twice (once per request handler that triggered the limit)
1895+
const enqueueLimitMessages = infoSpy.mock.calls.filter(
1896+
(call) => typeof call[0] === 'string' && call[0].includes('enqueueLinks limit'),
1897+
);
1898+
expect(enqueueLimitMessages).toHaveLength(2);
1899+
});
1900+
1901+
test('maxCrawlDepth limit log message should only be logged once per run', async () => {
1902+
const requestQueue = await RequestQueue.open();
1903+
1904+
// Each handler will try to add URLs that exceed maxCrawlDepth
1905+
const requestsToAdd = Array.from({ length: 5 }, (_, i) => `http://example.com/child${i}`);
1906+
1907+
const crawler = new BasicCrawler({
1908+
requestQueue,
1909+
maxCrawlDepth: 1, // Only allow depth 0 (initial) and depth 1 (first level children)
1910+
requestHandler: async (context) => {
1911+
// Stop processing at depth 2 to avoid infinite loops
1912+
if (context.request.crawlDepth >= 2) {
1913+
return;
1914+
}
1915+
1916+
// This will add requests at depth+1, so initial requests add at depth 1 (allowed)
1917+
// and depth 1 requests add at depth 2 (blocked by maxCrawlDepth)
1918+
await context.enqueueLinks({
1919+
urls: requestsToAdd,
1920+
label: `depth-${context.request.crawlDepth + 1}`,
1921+
});
1922+
},
1923+
});
1924+
1925+
const infoSpy = vitest.spyOn(crawler.log, 'info');
1926+
1927+
// Run with two initial requests
1928+
// Each will enqueue children at depth 1, then those children will try to enqueue at depth 2 (blocked)
1929+
await crawler.run(['http://example.com/first', 'http://example.com/second']);
1930+
1931+
// The maxCrawlDepth limit message should only appear once per run, even though multiple requests triggered it
1932+
const maxCrawlDepthMessages = infoSpy.mock.calls.filter(
1933+
(call) => typeof call[0] === 'string' && call[0].includes('maxCrawlDepth'),
1934+
);
1935+
expect(maxCrawlDepthMessages).toHaveLength(1);
1936+
});
18121937
});
18131938

18141939
describe('addRequests input validation', () => {

0 commit comments

Comments
 (0)