Skip to main content

spire_core/crawler/
run.rs

1//! The run loop of a [`Crawler`].
2
3use std::cell::Cell;
4use std::collections::{HashMap, HashSet};
5use std::pin::pin;
6use std::sync::Arc;
7use std::time::Duration;
8
9use ::http::StatusCode;
10use tokio::task::{Id, JoinError, JoinSet};
11use tokio::time::{Instant, sleep_until, timeout};
12use tracing::Instrument;
13
14use crate::backend::Backend;
15use crate::context::{Context, FETCH, Fetch, FetchScope};
16use crate::crawler::concurrency::{Feedback, Signal};
17use crate::crawler::connector::Connector;
18use crate::crawler::event::{
19    Emitter, Event, Failure, OutcomeKind, RequestInfo, SkipReason, StopReason,
20};
21use crate::crawler::hosts::{Admit, Hosts};
22#[cfg(feature = "robots")]
23use crate::crawler::robots::{BackendSource, RobotsCache, RobotsSource, Verdict};
24use crate::crawler::{Crawler, Live, Stats, Tag};
25use crate::frontier::queue::Queue;
26use crate::frontier::seed::Seed;
27use crate::frontier::{Ack, NextSeed, Report};
28use crate::identity::{Health, Identity};
29use crate::sync::lock;
30use crate::worker::{Outcome, Scope, Worker};
31use crate::{Error, ErrorKind, Result, TRACING_TARGET};
32
33/// Seeds parked for busy hosts at most, before the frontier stops being
34/// read; bounds memory when one host dominates the frontier.
35const MAX_PARKED: usize = 4096;
36
37/// The robots.txt state of a run.
38#[cfg(feature = "robots")]
39struct RobotsRun {
40    cache: RobotsCache,
41    source: Arc<dyn RobotsSource>,
42    agent: Arc<str>,
43    tasks: JoinSet<RobotsEvent>,
44}
45
46/// A fetched robots.txt: the origin and its rules, or why it is unreachable.
47#[cfg(feature = "robots")]
48type RobotsEvent = (String, Result<robotxt::Robots>);
49
50/// Without the `robots` feature, no robots.txt state exists.
51#[cfg(not(feature = "robots"))]
52enum RobotsRun {}
53
54#[cfg(not(feature = "robots"))]
55type RobotsEvent = std::convert::Infallible;
56
57/// Waits for the next fetched robots.txt; never resolves without fetches.
58async fn next_robots(robots: &mut Option<RobotsRun>) -> Option<RobotsEvent> {
59    #[cfg(feature = "robots")]
60    if let Some(robots) = robots
61        && let Some(joined) = robots.tasks.join_next().await
62    {
63        return joined.ok();
64    }
65
66    #[cfg(not(feature = "robots"))]
67    let _ = robots;
68    std::future::pending().await
69}
70
71/// Whether robots.txt lets a seed be crawled.
72#[cfg_attr(
73    not(feature = "robots"),
74    expect(dead_code, reason = "robots.txt only disallows with `robots`")
75)]
76enum Access {
77    Allow(Seed),
78    Disallow(Seed),
79    Parked,
80}
81
82/// The state of a single run.
83struct Run<B> {
84    tasks: JoinSet<(Outcome, Fetch)>,
85    /// The running requests.
86    in_flight: HashMap<Id, InFlight>,
87    robots: Option<RobotsRun>,
88    hosts: Hosts,
89    /// Connects for requests and robots.txt, as an identity or plainly.
90    connector: Arc<Connector<B>>,
91    events: Emitter,
92    aborted: HashSet<Option<Tag>>,
93    started: usize,
94    stopping: bool,
95    grace: Option<Instant>,
96    error: Option<Error>,
97}
98
99/// A running request.
100struct InFlight {
101    seed: Seed,
102    /// The identity it went out as, if any.
103    identity: Option<Identity>,
104    /// Its host's key, if hosts are tracked.
105    host: Option<String>,
106    /// When it started.
107    started: Instant,
108}
109
110/// The event for a request handled with `outcome`.
111fn handled(request: RequestInfo, outcome: &Outcome, elapsed: Duration) -> Event {
112    let error = match outcome {
113        Outcome::Fail(error) | Outcome::Fatal(error) => Some(Failure::from(error)),
114        _ => None,
115    };
116    Event::Handled {
117        request,
118        outcome: OutcomeKind::from(outcome),
119        error,
120        elapsed,
121    }
122}
123
124/// How a request went, as far as its host is concerned: a host answering
125/// `429` or `503`, or timing out, is throttling the crawl. A handler's
126/// backoff pauses its tag, not the host, so it is neither a success nor
127/// throttling.
128pub(super) fn signal(outcome: &Outcome) -> Signal {
129    match outcome {
130        Outcome::Fail(error)
131            if error.kind() == ErrorKind::Timeout
132                || matches!(
133                    error.status().map(|status| status.as_u16()),
134                    Some(429 | 503)
135                ) =>
136        {
137            Signal::Throttled
138        }
139        Outcome::Fail(_) | Outcome::Fatal(_) | Outcome::Retry { .. } | Outcome::Backoff { .. } => {
140            Signal::Failed
141        }
142        Outcome::Continue | Outcome::Abort(_) | Outcome::Stop => Signal::Ok,
143    }
144}
145
146impl<B: Backend> Run<B> {
147    /// Records that a request for `seed` to `host` finished with `outcome`:
148    /// a throttled host is slowed down, and one with `Retry-After` paused.
149    fn finish_host(
150        &mut self,
151        host: Option<String>,
152        seed: &Seed,
153        started: Instant,
154        fetch: Fetch,
155        outcome: &Outcome,
156    ) {
157        let now = Instant::now();
158        // A retryable error with `Retry-After` pauses the host it came from
159        // for exactly as long, without slowing it down further: the host
160        // said how long to wait, so there is nothing to guess.
161        let pause = match outcome {
162            Outcome::Fail(error) if error.is_retryable() => error.retry_after(),
163            _ => None,
164        };
165        let signal = if pause.is_some() {
166            Signal::Failed
167        } else {
168            signal(outcome)
169        };
170        // Hosts are tracked as soon as one is throttled, so it slows down
171        // even in a crawl without limits or gaps.
172        let throttled = signal == Signal::Throttled || pause.is_some();
173        let Some(host) = host.or_else(|| throttled.then(|| self.hosts.track(seed))) else {
174            return;
175        };
176        let feedback = Feedback {
177            started,
178            latency: fetch.latency,
179            unchanged: fetch.status == Some(StatusCode::NOT_MODIFIED),
180            signal,
181        };
182        if let Some(slowdown) = self.hosts.finished(&host, Some(feedback), now) {
183            self.events.emit(|| Event::HostSlowed {
184                host: host.clone(),
185                slowdown,
186            });
187        }
188        if let Some(pause) = pause
189            && self.hosts.pause(&host, now + pause)
190        {
191            self.events.emit(|| Event::HostPaused {
192                host,
193                duration: pause,
194            });
195        }
196    }
197
198    fn stop(&mut self, reason: StopReason, grace: Option<Duration>) {
199        if self.stopping {
200            return;
201        }
202        tracing::info!(target: TRACING_TARGET, in_flight = self.tasks.len(), %reason, "stopping crawl");
203        self.events.emit(|| Event::Stopping { reason });
204        self.stopping = true;
205        self.grace = grace.map(|grace| Instant::now() + grace);
206    }
207
208    /// Stops the run after a fatal failure; the error is returned once
209    /// in-flight requests are drained.
210    fn fail(&mut self, error: Error, grace: Option<Duration>) {
211        tracing::error!(target: TRACING_TARGET, %error, "crawl failed");
212        self.error.get_or_insert(error);
213        self.stop(StopReason::Failed, grace);
214    }
215
216    fn is_aborted(&self, seed: &Seed) -> bool {
217        self.aborted.contains(&seed.tag().cloned())
218    }
219
220    /// Whether robots.txt fetches are in flight.
221    #[cfg_attr(
222        not(feature = "robots"),
223        expect(clippy::unused_self, reason = "no robots.txt state")
224    )]
225    fn robots_busy(&self) -> bool {
226        #[cfg(feature = "robots")]
227        return self
228            .robots
229            .as_ref()
230            .is_some_and(|robots| !robots.tasks.is_empty());
231        #[cfg(not(feature = "robots"))]
232        false
233    }
234
235    /// Asks robots.txt whether `seed` may be crawled.
236    #[cfg_attr(
237        not(feature = "robots"),
238        expect(clippy::unused_self, reason = "no robots.txt state")
239    )]
240    fn robots_access(&mut self, seed: Seed) -> Access {
241        #[cfg(feature = "robots")]
242        if let Some(robots) = &mut self.robots {
243            return match robots.cache.admit(seed, Instant::now()) {
244                Verdict::Allow(seed) => Access::Allow(seed),
245                Verdict::Disallow(seed) => Access::Disallow(seed),
246                Verdict::Parked => Access::Parked,
247            };
248        }
249        Access::Allow(seed)
250    }
251
252    /// Starts the robots.txt fetches due, returning when the next blocked
253    /// origin is retried.
254    #[cfg_attr(
255        not(feature = "robots"),
256        expect(clippy::unused_self, reason = "no robots.txt state")
257    )]
258    fn robots_tick(&mut self) -> Option<Instant> {
259        #[cfg(feature = "robots")]
260        if let Some(robots) = &mut self.robots {
261            robots.cache.retry_due(Instant::now());
262            for (origin, url) in robots.cache.take_fetches() {
263                let fetch = robots.source.fetch(url, robots.agent.clone());
264                robots.tasks.spawn(async move { (origin, fetch.await) });
265            }
266            return robots.cache.earliest();
267        }
268        None
269    }
270
271    /// The number of seeds parked for hosts or robots.txt.
272    fn parked(&self) -> usize {
273        #[cfg(feature = "robots")]
274        let robots = self
275            .robots
276            .as_ref()
277            .map_or(0, |robots| robots.cache.parked());
278        #[cfg(not(feature = "robots"))]
279        let robots = 0;
280        self.hosts.parked() + robots
281    }
282
283    /// Takes every parked seed, e.g. when the run stops.
284    fn drain(&mut self) -> Vec<Seed> {
285        let seeds = self.hosts.drain();
286        #[cfg(feature = "robots")]
287        let seeds = match &mut self.robots {
288            Some(robots) => {
289                robots.tasks.abort_all();
290                seeds.into_iter().chain(robots.cache.drain()).collect()
291            }
292            None => seeds,
293        };
294        seeds
295    }
296}
297
298impl<B, W> Crawler<B, W>
299where
300    B: Backend,
301    W: Worker<B::Session>,
302{
303    /// Crawls until the frontier is empty and no request is in flight, a
304    /// worker returns [`Outcome::Stop`], the budget is spent, or the run is
305    /// shut down.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if a seed cannot be pushed, or if the frontier fails
310    /// mid-run; requests in flight are drained first.
311    pub async fn run(&self) -> Result<Stats> {
312        let seeds = std::mem::take(&mut *lock(&self.seeds));
313        for seed in seeds {
314            self.frontier.push(seed).await?;
315        }
316
317        let started = Instant::now();
318        *lock(&self.live) = Live {
319            stats: Stats::default(),
320            started: Some(started),
321            running: true,
322        };
323
324        let settings = &self.settings;
325        let deadline = settings.time_limit.map(|limit| started + limit);
326        let mut run = self.new_run();
327        tracing::info!(target: TRACING_TARGET, concurrency = settings.concurrency.get(), "crawl started");
328        self.events.emit(|| Event::Started);
329
330        // Timers and the shutdown signal are created once and re-armed as
331        // needed: `select!` would otherwise build them again for every
332        // finished request.
333        let mut wait_timer = pin!(sleep_until(started));
334        let mut armed_wait = None;
335        let mut deadline_timer = pin!(sleep_until(deadline.unwrap_or(started)));
336        let mut grace_timer = pin!(sleep_until(started));
337        let mut armed_grace = None;
338        let mut shutdown = pin!(self.shutdown.cancelled());
339
340        loop {
341            let wait = if run.stopping {
342                None
343            } else {
344                self.fill(&mut run).await
345            };
346
347            if run.tasks.is_empty() && !run.robots_busy() && (run.stopping || wait.is_none()) {
348                break;
349            }
350            if let Some(at) = wait
351                && armed_wait != wait
352            {
353                wait_timer.as_mut().reset(at);
354                armed_wait = wait;
355            }
356            if let Some(at) = run.grace
357                && armed_grace != run.grace
358            {
359                grace_timer.as_mut().reset(at);
360                armed_grace = run.grace;
361            }
362
363            // Biased: the rare control branches are checked first, so a steady
364            // stream of finished requests cannot delay a shutdown.
365            tokio::select! {
366                biased;
367                () = &mut shutdown, if !run.stopping => {
368                    run.stop(StopReason::Shutdown, settings.shutdown_grace);
369                }
370                () = &mut deadline_timer, if deadline.is_some() && !run.stopping => {
371                    run.stop(StopReason::TimeLimit, settings.shutdown_grace);
372                }
373                () = &mut grace_timer, if run.grace.is_some() => {
374                    tracing::warn!(target: TRACING_TARGET, in_flight = run.tasks.len(), "shutdown grace period over, cancelling requests");
375                    run.grace = None;
376                    run.tasks.abort_all();
377                }
378                Some(joined) = run.tasks.join_next_with_id() => self.complete(&mut run, joined).await,
379                Some(fetched) = next_robots(&mut run.robots) => self.robots_fetched(&mut run, fetched).await,
380                () = &mut wait_timer, if wait.is_some() => {
381                    armed_wait = None;
382                }
383            }
384        }
385
386        for seed in run.drain() {
387            self.report(&mut run, Report::Cancelled(seed)).await;
388        }
389
390        let stats = {
391            let mut live = lock(&self.live);
392            live.running = false;
393            live.stats.elapsed = started.elapsed();
394            live.stats
395        };
396        tracing::info!(
397            target: TRACING_TARGET,
398            requests = stats.requests,
399            succeeded = stats.succeeded,
400            failed = stats.failed,
401            retried = stats.retried,
402            skipped = stats.skipped,
403            cancelled = stats.cancelled,
404            elapsed_ms = stats.elapsed.as_millis(),
405            "crawl finished",
406        );
407        self.events.emit(|| Event::Finished { stats });
408
409        match run.error {
410            Some(error) => Err(error),
411            None => Ok(stats),
412        }
413    }
414
415    /// Sets up the state of a run: its identities, robots.txt and hosts.
416    fn new_run(&self) -> Run<B> {
417        let backend = self.backend.clone();
418        let connector = Arc::new(match &self.identities {
419            Some(identities) => Connector::with_identities(backend, identities.clone()),
420            None => Connector::new(backend),
421        });
422        let robots = self.robots_run(&connector);
423        // A robots.txt `Crawl-delay` may slow a host down, so hosts are
424        // tracked whenever robots.txt is obeyed.
425        let hosts = Hosts::new(self.settings.hosts, robots.is_some());
426        Run {
427            tasks: JoinSet::new(),
428            in_flight: HashMap::new(),
429            robots,
430            hosts,
431            connector,
432            events: self.events.clone(),
433            aborted: HashSet::new(),
434            started: 0,
435            stopping: false,
436            grace: None,
437            error: None,
438        }
439    }
440
441    /// Starts requests until the concurrency limit or budget is reached or
442    /// no seed is ready, returning when the next one will be.
443    async fn fill(&self, run: &mut Run<B>) -> Option<Instant> {
444        let settings = &self.settings;
445        let mut wait = run.robots_tick();
446
447        while run.tasks.len() < settings.concurrency.get() {
448            if settings.max_requests.is_some_and(|max| run.started >= max) {
449                run.stop(StopReason::RequestBudget, settings.shutdown_grace);
450                return None;
451            }
452
453            if let Some(seed) = run.hosts.take_ready(Instant::now()) {
454                self.start(run, seed).await;
455                continue;
456            }
457            if run.parked() >= MAX_PARKED {
458                break;
459            }
460
461            match self.frontier.pop().await {
462                Ok(NextSeed::Ready(seed)) => self.admit(run, seed).await,
463                Ok(NextSeed::Wait(at)) => {
464                    wait = wait.min(Some(at)).or(Some(at));
465                    break;
466                }
467                Ok(NextSeed::Empty) => break,
468                Err(error) => {
469                    run.fail(error, settings.shutdown_grace);
470                    return None;
471                }
472            }
473        }
474
475        let robots = run.robots_tick();
476        [wait, robots, run.hosts.earliest()]
477            .into_iter()
478            .flatten()
479            .min()
480    }
481
482    /// Starts a popped seed now, parks it until robots.txt and its host
483    /// allow it, or skips it if robots.txt disallows it.
484    async fn admit(&self, run: &mut Run<B>, seed: Seed) {
485        match run.robots_access(seed) {
486            Access::Allow(seed) => match run.hosts.admit(seed, Instant::now()) {
487                Admit::Start(seed) => self.start(run, seed).await,
488                Admit::Parked => {}
489            },
490            Access::Disallow(seed) => self.skip(run, seed, SkipReason::Disallowed).await,
491            Access::Parked => {}
492        }
493    }
494
495    /// Builds the robots.txt state, fetching through the run's connector,
496    /// as one of its identities if it has any, unless
497    /// [`Robots`](crate::crawler::Robots) sets another backend.
498    #[cfg(feature = "robots")]
499    fn robots_run(&self, connector: &Arc<Connector<B>>) -> Option<RobotsRun> {
500        let config = self.settings.robots.clone()?;
501        let source = config
502            .source
503            .clone()
504            .unwrap_or_else(|| Arc::new(BackendSource::new(connector.clone())));
505
506        Some(RobotsRun {
507            agent: config.agent.clone(),
508            cache: RobotsCache::new(config),
509            source,
510            tasks: JoinSet::new(),
511        })
512    }
513
514    #[cfg(not(feature = "robots"))]
515    #[expect(clippy::unused_self, reason = "mirrors the signature with `robots`")]
516    const fn robots_run(&self, _connector: &Arc<Connector<B>>) -> Option<RobotsRun> {
517        None
518    }
519
520    /// Applies a fetched robots.txt and admits the seeds that waited for it.
521    #[cfg(feature = "robots")]
522    async fn robots_fetched(&self, run: &mut Run<B>, (origin, result): RobotsEvent) {
523        let Some(robots) = &mut run.robots else {
524            return;
525        };
526        run.events.emit(|| Event::RobotsFetched {
527            origin: origin.clone(),
528            error: result.as_ref().err().map(Failure::from),
529        });
530        let fetched = robots.cache.fetched(&origin, result, Instant::now());
531        if let Some(delay) = fetched.delay {
532            let host = origin
533                .split_once("://")
534                .map_or(origin.as_str(), |(_, host)| host);
535            run.hosts.raise_delay(host, delay);
536        }
537        for seed in fetched.sitemaps {
538            let uri = seed.uri().clone();
539            if let Err(error) = self.queue().push(seed).await {
540                tracing::warn!(target: TRACING_TARGET, %uri, %error, "failed to queue a sitemap");
541            }
542        }
543        for seed in fetched.released {
544            self.admit(run, seed).await;
545        }
546    }
547
548    #[cfg(not(feature = "robots"))]
549    #[expect(clippy::unused_self, reason = "mirrors the signature with `robots`")]
550    fn robots_fetched(&self, _run: &mut Run<B>, never: RobotsEvent) -> std::future::Ready<()> {
551        match never {}
552    }
553
554    /// Skips `seed` without crawling it.
555    async fn skip(&self, run: &mut Run<B>, seed: Seed, reason: SkipReason) {
556        tracing::debug!(target: TRACING_TARGET, uri = %seed.uri(), %reason, "skipping request");
557        self.tally(|stats| stats.skipped += 1);
558        run.events.emit(|| Event::Skipped {
559            request: (&seed).into(),
560            reason,
561        });
562        self.report(run, Report::Skipped(seed)).await;
563    }
564
565    /// Skips `seed` if its tag was aborted, and otherwise spawns the request.
566    async fn start(&self, run: &mut Run<B>, seed: Seed) {
567        if run.is_aborted(&seed) {
568            self.skip(run, seed, SkipReason::TagAborted).await;
569            return;
570        }
571
572        run.started += 1;
573        let now = Instant::now();
574        let host = run.hosts.start(&seed, now);
575        run.events.emit(|| Event::RequestStarted {
576            request: (&seed).into(),
577        });
578        let notify = run.events.notify(&seed);
579
580        let worker = self.worker.clone();
581        let queue = self.queue();
582        let datasets = self.datasets.clone();
583        let deadline = self.settings.request_timeout;
584        let span = tracing::debug_span!(
585            target: TRACING_TARGET,
586            "request",
587            method = %seed.method(),
588            uri = %seed.uri(),
589            tag = seed.tag().map(tracing::field::display),
590            depth = %seed.depth(),
591            attempt = %seed.attempt(),
592        );
593
594        // The identity is picked now, as the request starts, so a failure to
595        // pick one fails the request like any other.
596        let connector = run.connector.clone();
597        let mut request = seed.clone().into_request();
598        request.extensions_mut().extend(self.extensions.clone());
599        let identity = connector.identify(&seed, &mut request);
600        let used = identity.as_ref().ok().cloned().flatten();
601
602        let task = async move {
603            let started = Instant::now();
604            let handle = async {
605                let connected = match identity {
606                    Ok(identity) => connector.connect(identity.as_ref()).await,
607                    Err(error) => Err(error),
608                };
609                let client = match connected {
610                    Ok(client) => client,
611                    Err(error) => return Outcome::Fail(error),
612                };
613                let queue: Queue = queue.child_of(&request);
614                let cx = Context::from_parts(request, client, queue, datasets);
615                worker.handle(cx).await
616            };
617            // The context records the fetch into this task's scope, which
618            // outlives a timeout: a fetch that finished before the handlers
619            // ran out of time still counts.
620            let scope = FetchScope {
621                fetch: Cell::default(),
622                notify,
623            };
624            let (outcome, fetch) = FETCH
625                .scope(scope, async {
626                    let outcome = match deadline {
627                        Some(deadline) => timeout(deadline, handle).await.unwrap_or_else(|_| {
628                            let error =
629                                Error::new(ErrorKind::Timeout).with_message("request timed out");
630                            Outcome::Fail(error)
631                        }),
632                        None => handle.await,
633                    };
634                    (outcome, FETCH.with(|scope| scope.fetch.get()))
635                })
636                .await;
637
638            tracing::debug!(
639                target: TRACING_TARGET,
640                outcome = %OutcomeKind::from(&outcome),
641                elapsed_ms = started.elapsed().as_millis(),
642                "request handled",
643            );
644            (outcome, fetch)
645        };
646
647        // Boxed so spawning moves a pointer, not the whole request future.
648        let id = run.tasks.spawn(Box::pin(task.instrument(span))).id();
649        let in_flight = InFlight {
650            seed,
651            identity: used,
652            host,
653            started: now,
654        };
655        run.in_flight.insert(id, in_flight);
656    }
657
658    /// Records a finished request and reports its outcome to the frontier.
659    async fn complete(&self, run: &mut Run<B>, joined: Result<(Id, (Outcome, Fetch)), JoinError>) {
660        let (id, (outcome, fetch)) = match joined {
661            Ok(joined) => joined,
662            Err(error) if error.is_cancelled() => {
663                let Some(InFlight { seed, host, .. }) = run.in_flight.remove(&error.id()) else {
664                    return;
665                };
666                if let Some(host) = host {
667                    run.hosts.finished(&host, None, Instant::now());
668                }
669                self.tally(|stats| stats.cancelled += 1);
670                run.events.emit(|| Event::Cancelled {
671                    request: (&seed).into(),
672                });
673                self.report(run, Report::Cancelled(seed)).await;
674                return;
675            }
676            Err(error) => {
677                let failure = Error::worker("worker panicked").with_source(error.to_string());
678                (error.id(), (Outcome::Fail(failure), Fetch::default()))
679            }
680        };
681        let Some(InFlight {
682            seed,
683            identity,
684            host,
685            started,
686        }) = run.in_flight.remove(&id)
687        else {
688            return;
689        };
690        // Built once for the events that follow, if anyone is subscribed.
691        let request = run.events.is_active().then(|| RequestInfo::from(&seed));
692        run.events.emit_for(request.as_ref(), |request| {
693            handled(request, &outcome, started.elapsed())
694        });
695
696        run.finish_host(host, &seed, started, fetch, &outcome);
697        if let Some(identity) = &identity {
698            run.connector
699                .finished(identity, Health::of(&outcome, fetch.status));
700        }
701        self.tally(|stats| {
702            stats.requests += 1;
703            if fetch.status == Some(StatusCode::NOT_MODIFIED) {
704                stats.unchanged += 1;
705            }
706        });
707
708        self.settle(run, seed, outcome, request).await;
709    }
710
711    /// Settles a handled request: applies what its outcome asks of the run,
712    /// reports it to the frontier, and counts how it ended.
713    async fn settle(
714        &self,
715        run: &mut Run<B>,
716        seed: Seed,
717        outcome: Outcome,
718        request: Option<RequestInfo>,
719    ) {
720        let uri = seed.uri().clone();
721        let mut failure = None;
722        let mut failed = false;
723        let report = match outcome {
724            Outcome::Continue => {
725                self.tally(|stats| stats.succeeded += 1);
726                Report::Done(seed)
727            }
728            Outcome::Stop => {
729                self.tally(|stats| stats.succeeded += 1);
730                run.stop(StopReason::Requested, self.settings.shutdown_grace);
731                Report::Done(seed)
732            }
733            Outcome::Abort(scope) => {
734                let tag = match scope {
735                    Scope::SameTag => seed.tag().cloned(),
736                    Scope::Tag(tag) => Some(tag),
737                };
738                tracing::info!(target: TRACING_TARGET, tag = tag.as_ref().map(tracing::field::display), "aborting tag");
739                run.aborted.insert(tag);
740                self.tally(|stats| stats.succeeded += 1);
741                Report::Done(seed)
742            }
743            Outcome::Retry { after } => Report::Retry { seed, after },
744            Outcome::Backoff { scope, duration } => self.backoff(run, seed, scope, duration).await,
745            Outcome::Fatal(error) => {
746                failed = true;
747                run.fail(error.with_uri(uri.clone()), self.settings.shutdown_grace);
748                Report::Failed(seed)
749            }
750            Outcome::Fail(error) => {
751                let error = match error.uri() {
752                    Some(_) => error,
753                    None => error.with_uri(uri.clone()),
754                };
755                if error.is_retryable() {
756                    tracing::debug!(target: TRACING_TARGET, %uri, %error, "request failed, retrying");
757                    // A `Retry-After` paused the host above; the request
758                    // itself waits as long.
759                    let after = error.retry_after();
760                    failure = Some(error);
761                    Report::Retry { seed, after }
762                } else {
763                    tracing::warn!(target: TRACING_TARGET, %uri, %error, "request failed");
764                    failed = true;
765                    Report::Failed(seed)
766                }
767            }
768        };
769
770        let retry = match &report {
771            Report::Retry { after, .. } => Some(*after),
772            _ => None,
773        };
774        match self.report(run, report).await {
775            Some(Ack::Applied) if let Some(after) = retry => {
776                self.tally(|stats| stats.retried += 1);
777                run.events
778                    .emit_for(request.as_ref(), |request| Event::Retried {
779                        request,
780                        after,
781                    });
782            }
783            Some(Ack::RetryRefused) => {
784                failed = true;
785                tracing::warn!(
786                    target: TRACING_TARGET,
787                    %uri,
788                    error = failure.as_ref().map(tracing::field::display),
789                    "request failed, out of retries",
790                );
791            }
792            _ => {}
793        }
794        if failed {
795            self.tally(|stats| stats.failed += 1);
796            run.events
797                .emit_for(request.as_ref(), |request| Event::Failed { request });
798        }
799    }
800
801    /// Pauses `scope` for `duration`, returning the report that retries
802    /// `seed` once the pause ends.
803    async fn backoff(
804        &self,
805        run: &mut Run<B>,
806        seed: Seed,
807        scope: Scope,
808        duration: Duration,
809    ) -> Report {
810        let tag = match scope {
811            Scope::SameTag => seed.tag().cloned(),
812            Scope::Tag(tag) => Some(tag),
813        };
814        tracing::debug!(
815            target: TRACING_TARGET,
816            tag = tag.as_ref().map(tracing::field::display),
817            duration_ms = duration.as_millis(),
818            "backing off",
819        );
820
821        let until = Instant::now() + duration;
822        self.report(run, Report::Backoff { tag, until }).await;
823        Report::Retry {
824            seed,
825            after: Some(duration),
826        }
827    }
828
829    /// Reports to the frontier; a failure stops the run.
830    async fn report(&self, run: &mut Run<B>, report: Report) -> Option<Ack> {
831        match self.frontier.report(report).await {
832            Ok(ack) => Some(ack),
833            Err(error) => {
834                run.fail(error, self.settings.shutdown_grace);
835                None
836            }
837        }
838    }
839}