Skip to content

Commit 92c9a8f

Browse files
committed
fix(crawl): do not fail a job because robots.txt could not be read
An origin that answers `/robots.txt` with a 503, or refuses the connection, has failed to serve a file. It has not thereby forbidden anything. Both crawl surfaces treated that as a terminal error: `run_crawl` marked the job Failed without fetching the seed, and `discover_urls` returned `TargetUnreachable` with zero URLs. Any origin whose robots.txt went down could stop a crawl outright, and every page it never asked us to withhold went unfetched. Against a local origin serving a 503 on `/robots.txt` and 200 on everything else, with robots enforcement on: before error: crawl failed: robots.txt unreachable (0 pages) after Crawl completed: 2 pages Both surfaces now log which origin went dark and carry on with no rules, which is what they did before this branch. Two things follow from that: - `RobotsTxt::fetch` returns `CrwResult<Self>`. The `Option` distinguished a 4xx from a 5xx, and now that every caller proceeds either way there was nothing left to distinguish. A 4xx is an empty rule set; a 5xx is still an error so the caller has something to log. - `discover_urls` gives the robots fetch half the remaining budget instead of all of it. A robots.txt that hangs used to be handed the whole deadline, so the seed and the sitemap probes got nothing and the caller saw zero URLs even before the fail-closed arm was reached.
1 parent ccb34ff commit 92c9a8f

4 files changed

Lines changed: 110 additions & 74 deletions

File tree

crates/crw-crawl/src/crawl.rs

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -288,20 +288,18 @@ async fn run_crawl_inner(opts: CrawlOptions<'_>) {
288288
.build()
289289
.expect("reqwest client build should not fail");
290290

291-
// `Ok(None)` is robots.txt absent (a 4xx), which means no rules, not a
292-
// failure. `Err` is the origin itself being unreachable, and with policy
293-
// enforcement on that fails the job rather than crawling unrestrained.
294-
let robots = if respect_robots {
295-
match RobotsTxt::fetch(&origin, &client).await {
296-
Ok(found) => found.unwrap_or_default(),
297-
Err(e) => {
298-
send_failed(id, &state_tx, format!("robots.txt unreachable: {e}"));
299-
return;
300-
}
301-
}
302-
} else {
303-
RobotsTxt::default()
304-
};
291+
// Fails open. An unreadable robots.txt is not a licence to fail the job:
292+
// the crawl proceeds with no rules, so an origin that 503s the file cannot
293+
// stop a crawl outright.
294+
let robots =
295+
if respect_robots {
296+
RobotsTxt::fetch(&origin, &client).await.unwrap_or_else(|e| {
297+
tracing::warn!(error = %e, "robots.txt unavailable, crawling without its rules");
298+
RobotsTxt::default()
299+
})
300+
} else {
301+
RobotsTxt::default()
302+
};
305303

306304
let semaphore = Arc::new(Semaphore::new(max_concurrency));
307305
// Key the rate limiter by eTLD+1 so subdomains under the same registered
@@ -913,23 +911,26 @@ pub async fn discover_urls(opts: DiscoverOptions<'_>) -> CrwResult<DiscoverResul
913911
// The client's own 15s timeout is longer than a short caller timeout, so it
914912
// is additionally clamped by the overall deadline — otherwise a slow
915913
// robots.txt alone could burn the whole budget and lose every result.
916-
let timed_out =
917-
|| crw_core::error::CrwError::TargetUnreachable("robots.txt request timed out".into());
918-
let fetched = match remaining_budget(overall_deadline) {
919-
Some(budget) => tokio::time::timeout(budget, RobotsTxt::fetch(&origin, &client))
920-
.await
921-
.unwrap_or_else(|_| Err(timed_out())),
922-
// No budget left to spend on it at all.
923-
None => Err(timed_out()),
924-
};
925-
// `Ok(None)` is robots.txt absent, which is no rules rather than a failure.
926-
let robots = match fetched {
927-
Ok(found) => found.unwrap_or_default(),
928-
Err(error) if respect_robots => return Err(error),
929-
Err(error) => {
930-
tracing::warn!(error = %error, "robots.txt unavailable, continuing without its rules");
931-
RobotsTxt::default()
914+
// Half the remaining budget, not all of it: a robots.txt that hangs used to
915+
// be handed the whole deadline, so the seed and the sitemap probes got
916+
// nothing and discovery came back with zero URLs. Half leaves the work its
917+
// share. Like `run_crawl` this fails open, because an origin that cannot
918+
// serve the file has not thereby forbidden anything.
919+
let robots = match remaining_budget(overall_deadline) {
920+
Some(budget) => {
921+
match tokio::time::timeout(budget / 2, RobotsTxt::fetch(&origin, &client)).await {
922+
Ok(Ok(robots)) => robots,
923+
Ok(Err(error)) => {
924+
tracing::warn!(error = %error, "robots.txt unavailable, discovering without its rules");
925+
RobotsTxt::default()
926+
}
927+
Err(_) => {
928+
tracing::warn!("robots.txt fetch timed out, discovering without its rules");
929+
RobotsTxt::default()
930+
}
931+
}
932932
}
933+
None => RobotsTxt::default(),
933934
};
934935

935936
if use_sitemap {

crates/crw-crawl/src/robots.rs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,16 @@ pub struct RobotsTxt {
1818
}
1919

2020
impl RobotsTxt {
21-
pub async fn fetch(base_url: &str, client: &reqwest::Client) -> CrwResult<Option<Self>> {
21+
/// Fetch and parse an origin's `/robots.txt`.
22+
///
23+
/// A 4xx is a site that simply has no robots.txt, which is no rules rather
24+
/// than a problem, so it comes back as an empty rule set. A 5xx or a
25+
/// transport failure comes back as an error, but only so the caller can log
26+
/// which origin went dark: every caller proceeds with no rules either way.
27+
/// Failing a job on an unreadable robots.txt would hand any origin that
28+
/// 503s the file a way to stop a crawl outright, and the pages it never
29+
/// asked us to withhold would go unfetched.
30+
pub async fn fetch(base_url: &str, client: &reqwest::Client) -> CrwResult<Self> {
2231
let url = format!("{}/robots.txt", base_url.trim_end_matches('/'));
2332

2433
let resp = client.get(&url).send().await.map_err(|e| {
@@ -29,7 +38,7 @@ impl RobotsTxt {
2938
})?;
3039

3140
if resp.status().is_client_error() {
32-
return Ok(None);
41+
return Ok(Self::default());
3342
}
3443
if !resp.status().is_success() {
3544
return Err(CrwError::TargetUnreachable(format!(
@@ -57,7 +66,7 @@ impl RobotsTxt {
5766
bytes.truncate(end);
5867
}
5968
let text = String::from_utf8_lossy(&bytes);
60-
Ok(Some(Self::parse(&text)))
69+
Ok(Self::parse(&text))
6170
}
6271

6372
/// Parse into the rules that bind us.
@@ -441,18 +450,19 @@ Allow: /path
441450
}
442451

443452
#[tokio::test]
453+
/// A site with no robots.txt forbids nothing; an origin that cannot serve
454+
/// the file reports an error the caller logs and then ignores. Both end up
455+
/// crawling, which is what the callers assert.
444456
async fn fetch_classifies_unavailable_and_unreachable_statuses() {
445457
let unavailable = wiremock::MockServer::start().await;
446458
wiremock::Mock::given(wiremock::matchers::path("/robots.txt"))
447459
.respond_with(wiremock::ResponseTemplate::new(404))
448460
.mount(&unavailable)
449461
.await;
450-
assert!(
451-
RobotsTxt::fetch(&unavailable.uri(), &reqwest::Client::new())
452-
.await
453-
.unwrap()
454-
.is_none()
455-
);
462+
let absent = RobotsTxt::fetch(&unavailable.uri(), &reqwest::Client::new())
463+
.await
464+
.expect("a missing robots.txt is not an error");
465+
assert!(absent.is_allowed("/anything"), "no file means no rules");
456466

457467
let unreachable = wiremock::MockServer::start().await;
458468
wiremock::Mock::given(wiremock::matchers::path("/robots.txt"))
@@ -506,7 +516,6 @@ Allow: /path
506516

507517
let robots = RobotsTxt::fetch(&server.uri(), &reqwest::Client::new())
508518
.await
509-
.unwrap()
510519
.unwrap();
511520
assert!(!robots.is_allowed("/blocked"));
512521
}

crates/crw-crawl/tests/crawl_headers_and_failures.rs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ async fn crawl_honours_a_robots_rule_keyed_on_the_query_string() {
349349
}
350350

351351
#[tokio::test]
352-
async fn unreachable_robots_fails_the_job_before_fetching_the_seed() {
352+
async fn a_robots_txt_we_cannot_read_does_not_fail_the_crawl() {
353353
let server = MockServer::start().await;
354354
Mock::given(method("GET"))
355355
.and(path("/robots.txt"))
@@ -359,19 +359,23 @@ async fn unreachable_robots_fails_the_job_before_fetching_the_seed() {
359359
.await;
360360
Mock::given(method("GET"))
361361
.and(path("/"))
362-
.respond_with(ResponseTemplate::new(200))
363-
.expect(0)
362+
.respond_with(
363+
ResponseTemplate::new(200)
364+
.set_body_string("<html><body><h1>Seed page</h1></body></html>")
365+
.insert_header("content-type", "text/html"),
366+
)
367+
.expect(1)
364368
.mount(&server)
365369
.await;
366370

367371
let state = run_with_robots(request(format!("{}/", server.uri())), true).await;
368372

369-
assert_eq!(state.status, CrawlStatus::Failed);
370-
assert!(!state.success);
373+
assert_eq!(state.status, CrawlStatus::Completed);
374+
assert!(state.success);
375+
assert_eq!(state.data.len(), 1, "the seed must still be crawled");
371376
assert!(
372-
state
373-
.error
374-
.as_deref()
375-
.is_some_and(|error| error.contains("robots.txt unreachable"))
377+
state.error.is_none(),
378+
"a 503 on robots.txt is logged, not surfaced as a job error: {:?}",
379+
state.error
376380
);
377381
}

crates/crw-crawl/tests/discover_tests.rs

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,13 @@ async fn slow_site_returns_partial_results_instead_of_timing_out() {
238238
);
239239
}
240240

241-
/// A robots.txt timeout fails closed before sitemap probes or seed crawling.
241+
/// A hanging robots.txt must not consume the whole budget, and must not stop
242+
/// discovery. It used to be handed the entire remaining deadline, so the seed
243+
/// and the sitemap probes got nothing left and the caller saw zero URLs; then
244+
/// it additionally failed the request outright. Now it gets half the budget and
245+
/// discovery carries on without its rules.
242246
#[tokio::test]
243-
async fn hanging_robots_fails_closed_within_the_overall_budget() {
247+
async fn hanging_robots_leaves_budget_for_the_seed() {
244248
let server = MockServer::start().await;
245249

246250
Mock::given(method("GET"))
@@ -251,13 +255,15 @@ async fn hanging_robots_fails_closed_within_the_overall_budget() {
251255
Mock::given(method("GET"))
252256
.and(path("/sitemap.xml"))
253257
.respond_with(ResponseTemplate::new(404))
254-
.expect(0)
255258
.mount(&server)
256259
.await;
257260
Mock::given(method("GET"))
258261
.and(path("/"))
259-
.respond_with(ResponseTemplate::new(200).set_body_string(r#"<a href="/x">x</a>"#))
260-
.expect(0)
262+
.respond_with(
263+
ResponseTemplate::new(200)
264+
.insert_header("content-type", "text/html")
265+
.set_body_string(r#"<html><body><a href="/x">x</a></body></html>"#),
266+
)
261267
.mount(&server)
262268
.await;
263269

@@ -266,7 +272,7 @@ async fn hanging_robots_fails_closed_within_the_overall_budget() {
266272
let result = discover_urls(opts(
267273
&server.uri(),
268274
&r,
269-
Instant::now() + Duration::from_secs(3),
275+
Instant::now() + Duration::from_secs(8),
270276
true,
271277
))
272278
.await;
@@ -276,13 +282,24 @@ async fn hanging_robots_fails_closed_within_the_overall_budget() {
276282
"robots fetch must be clamped by the overall deadline, took {:?}",
277283
started.elapsed()
278284
);
279-
assert!(matches!(
280-
result,
281-
Err(crw_core::error::CrwError::TargetUnreachable(_))
282-
));
283-
let requests = server.received_requests().await.unwrap();
284-
assert_eq!(requests.len(), 1);
285-
assert_eq!(requests[0].url.path(), "/robots.txt");
285+
let urls = result
286+
.expect("a hanging robots.txt must not fail discovery")
287+
.urls;
288+
assert!(
289+
urls.iter().any(|u| u.contains(&server.uri())),
290+
"the seed must survive the robots hang, got {urls:?}"
291+
);
292+
let paths: Vec<String> = server
293+
.received_requests()
294+
.await
295+
.unwrap()
296+
.iter()
297+
.map(|r| r.url.path().to_string())
298+
.collect();
299+
assert!(
300+
paths.iter().any(|p| p != "/robots.txt"),
301+
"work must happen after the robots timeout, only saw {paths:?}"
302+
);
286303
}
287304

288305
/// `max_urls` is a hard cap, not a suggestion. The base URL used to be appended
@@ -327,8 +344,11 @@ async fn seed_validation_is_bounded_by_the_overall_deadline() {
327344
);
328345
}
329346

347+
/// A 5xx on robots.txt is the origin failing to serve a file, not the origin
348+
/// forbidding anything. Discovery proceeds to the seed and the sitemap probes
349+
/// with no rules, rather than returning the caller an error and zero URLs.
330350
#[tokio::test]
331-
async fn unreachable_robots_stops_discovery_before_seed_or_sitemap_requests() {
351+
async fn a_robots_txt_we_cannot_read_does_not_stop_discovery() {
332352
let server = MockServer::start().await;
333353
Mock::given(method("GET"))
334354
.and(path("/robots.txt"))
@@ -338,29 +358,31 @@ async fn unreachable_robots_stops_discovery_before_seed_or_sitemap_requests() {
338358
.await;
339359
Mock::given(method("GET"))
340360
.and(path("/"))
341-
.respond_with(ResponseTemplate::new(200))
342-
.expect(0)
361+
.respond_with(
362+
ResponseTemplate::new(200)
363+
.insert_header("content-type", "text/html")
364+
.set_body_string("<html><body>seed</body></html>"),
365+
)
343366
.mount(&server)
344367
.await;
345368
Mock::given(method("GET"))
346369
.and(path("/sitemap.xml"))
347-
.respond_with(ResponseTemplate::new(200))
348-
.expect(0)
370+
.respond_with(ResponseTemplate::new(404))
349371
.mount(&server)
350372
.await;
351373

352374
let renderer = renderer().await;
353-
let result = discover_urls(opts(
354-
&server.uri(),
375+
let uri = server.uri();
376+
let urls = discover_urls(opts(
377+
&uri,
355378
&renderer,
356379
Instant::now() + Duration::from_secs(10),
357380
true,
358381
))
359-
.await;
360-
assert!(matches!(
361-
result,
362-
Err(crw_core::error::CrwError::TargetUnreachable(_))
363-
));
382+
.await
383+
.expect("a 503 on robots.txt must not fail discovery")
384+
.urls;
385+
assert!(urls.iter().any(|u| u == &uri), "got {urls:?}");
364386
}
365387

366388
#[tokio::test]

0 commit comments

Comments
 (0)