tide_disco/lib.rs
1// Copyright (c) 2022 Espresso Systems (espressosys.com)
2// This file is part of the tide-disco library.
3
4// You should have received a copy of the MIT License
5// along with the tide-disco library. If not, see <https://mit-license.org/>.
6
7//! _Tide Disco is a web server framework with built-in discoverability support for
8//! [Tide](https://github.com/http-rs/tide)_
9//!
10//! # Overview
11//!
12//! We say a system is _discoverable_ if guesses and mistakes regarding usage are rewarded with
13//! relevant documentation and assistance at producing correct requests. To offer this capability in
14//! a practical way, it is helpful to specify the API in data files, rather than code, so that all
15//! relevant text can be edited in one concise readable specification.
16//!
17//! Tide Disco leverages TOML to specify
18//! - Routes with typed parameters
19//! - Route documentation
20//! - Route error messages
21//! - General documentation
22//!
23//! ## Goals
24//!
25//! - Context-sensitive help
26//! - Spelling suggestions
27//! - Reference documentation assembled from route documentation
28//! - Forms and other user interfaces to aid in the construction of correct inputs
29//! - Localization
30//! - Novice and expert help
31//! - Flexible route parsing, e.g. named parameters rather than positional parameters
32//! - API fuzz testing automation based on parameter types
33//!
34//! ## Future work
35//!
36//! - WebSocket support
37//! - Runtime control over logging
38//!
39//! # Getting started
40//!
41//! A Tide Disco app is composed of one or more _API modules_. An API module consists of a TOML
42//! specification and a set of route handlers -- Rust functions -- to provide the behavior of the
43//! routes defined in the TOML. You can learn the format of the TOML file by looking at the examples
44//! in this crate. Once you have it, you can load it into an API description using [Api::new]:
45//!
46//! ```no_run
47//! # fn main() -> Result<(), tide_disco::api::ApiError> {
48//! use tide_disco::Api;
49//! use tide_disco::error::ServerError;
50//! use vbs::version::StaticVersion;
51//!
52//! type State = ();
53//! type Error = ServerError;
54//! type StaticVer01 = StaticVersion<0, 1>;
55//!
56//! let spec: toml::Value = toml::from_str(
57//! std::str::from_utf8(&std::fs::read("/path/to/api.toml").unwrap()).unwrap(),
58//! ).unwrap();
59//! let mut api = Api::<State, Error, StaticVer01>::new(spec)?;
60//! # Ok(())
61//! # }
62//! ```
63//!
64//! Once you have an [Api], you can define route handlers for any routes in your TOML specification.
65//! Suppose you have the following route definition:
66//!
67//! ```toml
68//! [route.hello]
69//! PATH = ["hello"]
70//! METHOD = "GET"
71//! ```
72//!
73//! Register a handler for it like this:
74//!
75//! ```no_run
76//! # use tide_disco::Api;
77//! # use vbs::version::StaticVersion;
78//! # type StaticVer01 = StaticVersion<0, 1>;
79//! # fn main() -> Result<(), tide_disco::api::ApiError> {
80//! # let spec: toml::Value = toml::from_str(std::str::from_utf8(&std::fs::read("/path/to/api.toml").unwrap()).unwrap()).unwrap();
81//! # let mut api = Api::<(), tide_disco::error::ServerError, StaticVer01>::new(spec)?;
82//! use futures::FutureExt;
83//!
84//! api.get("hello", |req, state| async move { Ok("Hello, world!") }.boxed())?;
85//! # Ok(())
86//! # }
87//! ```
88//!
89//! See [the API reference](Api) for more details on what you can do to create an [Api].
90//!
91//! Once you have registered all of your route handlers, you need to register your [Api] module with
92//! an [App]:
93//!
94//! ```no_run
95//! # use vbs::version::StaticVersion;
96//! # type State = ();
97//! # type Error = tide_disco::error::ServerError;
98//! # type StaticVer01 = StaticVersion<0, 1>;
99//! # #[async_std::main] async fn main() {
100//! # let spec: toml::Value = toml::from_str(std::str::from_utf8(&std::fs::read("/path/to/api.toml").unwrap()).unwrap()).unwrap();
101//! # let api = tide_disco::Api::<State, Error, StaticVer01>::new(spec).unwrap();
102//! use tide_disco::App;
103//! use vbs::version::{StaticVersion, StaticVersionType};
104//!
105//! type StaticVer01 = StaticVersion<0, 1>;
106//!
107//! let mut app = App::<State, Error>::with_state(());
108//! app.register_module("api", api);
109//! app.serve("http://localhost:8080", StaticVer01::instance()).await;
110//! # }
111//! ```
112//!
113//! Then you can use your application:
114//!
115//! ```text
116//! curl http://localhost:8080/api/hello
117//! ```
118//!
119//! # Boxed futures
120//!
121//! As a web server framework, Tide Disco naturally includes many interfaces that take functions as
122//! arguments. For example, route handlers are registered by passing a handler function to an [Api]
123//! object. Also naturally, many of these function parameters are async, which of course just means
124//! that they are regular functions returning some type `F` that implements the
125//! [Future](futures::Future) trait. This is all perfectly usual, but throughout the interfaces in
126//! this crate, you may notice something that is a bit unusual: many of these functions are required
127//! to return not just any [Future](futures::Future), but a
128//! [BoxFuture](futures::future::BoxFuture). This is due to a limitation that currently exists
129//! in the Rust compiler.
130//!
131//! The problem arises with functions where the returned future is not `'static`, but rather borrows
132//! from the function parameters. Consider the following route definition, for example:
133//!
134//! ```ignore
135//! type State = RwLock<u64>;
136//! type Error = ();
137//!
138//! api.at("someroute", |_req, state: &State| async {
139//! Ok(*state.read().await)
140//! })
141//! ```
142//!
143//! The `async` block in the route handler uses the `state` reference, so the resulting future is
144//! only valid for as long as the reference `state` is valid. We could write the signature of the
145//! route handler like this:
146//!
147//! ```
148//! use futures::Future;
149//! use tide_disco::RequestParams;
150//!
151//! type State = async_std::sync::RwLock<u64>;
152//! type Error = ();
153//!
154//! fn handler<'a>(
155//! req: RequestParams,
156//! state: &'a State,
157//! ) -> impl 'a + Future<Output = Result<u64, Error>> {
158//! // ...
159//! # async { Ok(*state.read().await) }
160//! }
161//! ```
162//!
163//! Notice how we explicitly constrain the future type by the lifetime `'a` using `impl` syntax.
164//! Unfortunately, while we can write a function signature like this, we cannot write a type bound
165//! that uses the [Fn] trait and represents the equivalent function signature. This is a problem,
166//! since interfaces like [at](Api::at) would like to consume any function-like object which
167//! implements [Fn], not just static function pointers. Here is what we would _like_ to write:
168//!
169//! ```ignore
170//! impl<State, Error, VER: StaticVersionType> Api<State, Error, VER> {
171//! pub fn at<F, T>(&mut self, route: &str, handler: F)
172//! where
173//! F: for<'a> Fn<(RequestParams, &'a State)>,
174//! for<'a> <F as Fn<(RequestParams, &'a State)>>::Output:
175//! 'a + Future<Output = Result<T, Error>>,
176//! {...}
177//! }
178//! ```
179//!
180//! Here we are using a higher-rank trait bound on the associated type `Output` of the [Fn]
181//! implementation for `F` in order to constrain the future by the lifetime `'a`, which is the
182//! lifetime of the `State` reference. It is actually possible to write this function signature
183//! today in unstable Rust (using the raw [Fn] trait as a bound is unstable), but even then, no
184//! associated type will be able to implement the HRTB due to a bug in the compiler. This limitation
185//! is described in detail in
186//! [this post](https://users.rust-lang.org/t/trait-bounds-for-fn-returning-a-future-that-forwards-the-lifetime-of-the-fn-s-arguments/63275/7).
187//!
188//! As a workaround until this is fixed, we require the function `F` to return a concrete future
189//! type with an explicit lifetime parameter: [BoxFuture](futures::future::BoxFuture). This allows
190//! us to specify the lifetime constraint within the HRTB on `F` itself, rather than resorting to a
191//! separate HRTB on the associated type `Output` in order to be able to name the return type of
192//! `F`. Here is the actual (partial) signature of [at](Api::at):
193//!
194//! ```ignore
195//! impl<State, Error, VER: StaticVersionType> Api<State, Error, VER> {
196//! pub fn at<F, T>(&mut self, route: &str, handler: F)
197//! where
198//! F: for<'a> Fn(RequestParams, &'a State) -> BoxFuture<'a, Result<T, Error>>,
199//! {...}
200//! }
201//! ```
202//!
203//! What this means for your code is that functions you pass to the Tide Disco framework must return
204//! a boxed future. When passing a closure, you can simply add `.boxed()` to your `async` block,
205//! like this:
206//!
207//! ```
208//! use async_std::sync::RwLock;
209//! use futures::FutureExt;
210//! use tide_disco::Api;
211//! use vbs::version::StaticVersion;
212//!
213//! type State = RwLock<u64>;
214//! type Error = ();
215//!
216//! type StaticVer01 = StaticVersion<0, 1>;
217//!
218//! fn define_routes(api: &mut Api<State, Error, StaticVer01>) {
219//! api.at("someroute", |_req, state: &State| async {
220//! Ok(*state.read().await)
221//! }.boxed());
222//! }
223//! ```
224//!
225//! This also means that you cannot pass the name of an `async fn` directly, since `async` functions
226//! declared with the `async fn` syntax do not return a boxed future. Instead, you can wrap the
227//! function in a closure:
228//!
229//! ```
230//! use async_std::sync::RwLock;
231//! use futures::FutureExt;
232//! use tide_disco::{Api, RequestParams};
233//! use vbs::version::StaticVersion;
234//!
235//! type State = RwLock<u64>;
236//! type Error = ();
237//! type StaticVer01 = StaticVersion<0, 1>;
238//!
239//! async fn handler(_req: RequestParams, state: &State) -> Result<u64, Error> {
240//! Ok(*state.read().await)
241//! }
242//!
243//! fn register(api: &mut Api<State, Error, StaticVer01>) {
244//! api.at("someroute", |req, state: &State| handler(req, state).boxed());
245//! }
246//! ```
247//!
248//! In the future, we may create an attribute macro which can rewrite an `async fn` to return a
249//! boxed future directly, like
250//!
251//! ```ignore
252//! #[boxed_future]
253//! async fn handler(_req: RequestParams, state: &State) -> Result<u64, Error> {
254//! Ok(*state.read().await)
255//! }
256//! ```
257//!
258
259use crate::ApiKey::*;
260use async_std::sync::{Arc, RwLock};
261use async_std::task::sleep;
262use clap::CommandFactory;
263use config::{Config, ConfigError};
264use routefinder::Router;
265use serde::Deserialize;
266use std::fs::{read_to_string, OpenOptions};
267use std::io::Write;
268use std::str::FromStr;
269use std::time::Duration;
270use std::{
271 collections::HashMap,
272 env,
273 path::{Path, PathBuf},
274};
275use strum_macros::{AsRefStr, EnumString};
276use tagged_base64::TaggedBase64;
277use tide::http::mime;
278use toml::value::Value;
279use tracing::{error, trace};
280
281pub mod api;
282pub mod app;
283pub mod error;
284pub mod healthcheck;
285pub mod listener;
286pub mod method;
287pub mod metrics;
288pub mod request;
289pub mod socket;
290pub mod status;
291pub mod testing;
292
293mod dispatch;
294mod middleware;
295mod route;
296
297pub use api::Api;
298pub use app::App;
299pub use error::Error;
300pub use method::Method;
301pub use request::{RequestError, RequestParam, RequestParamType, RequestParamValue, RequestParams};
302pub use status::StatusCode;
303pub use tide::http;
304pub use url::Url;
305
306pub type Html = maud::Markup;
307
308/// Number of times to poll before failing
309pub const SERVER_STARTUP_RETRIES: u64 = 255;
310
311/// Number of milliseconds to sleep between attempts
312pub const SERVER_STARTUP_SLEEP_MS: u64 = 100;
313
314#[derive(clap::Args, Debug)]
315#[clap(author, version, about, long_about = None)]
316pub struct DiscoArgs {
317 #[clap(long)]
318 /// Server address
319 pub base_url: Option<Url>,
320 #[clap(long)]
321 /// HTTP routes
322 pub api_toml: Option<PathBuf>,
323 /// If true, log in color. Otherwise, no color.
324 #[clap(long)]
325 pub ansi_color: Option<bool>,
326}
327
328/// Configuration keys for Tide Disco settings
329///
330/// The application is expected to define additional keys. Note, string literals could be used
331/// directly, but defining an enum allows the compiler to catch typos.
332#[derive(AsRefStr, Debug)]
333#[allow(non_camel_case_types)]
334pub enum DiscoKey {
335 ansi_color,
336 api_toml,
337 app_toml,
338 base_url,
339 disco_toml,
340}
341
342#[derive(AsRefStr, Clone, Debug, Deserialize, strum_macros::Display)]
343pub enum HealthStatus {
344 Starting,
345 Available,
346 Stopping,
347}
348
349#[derive(Clone)]
350pub struct ServerState<AppState> {
351 pub health_status: Arc<RwLock<HealthStatus>>,
352 pub app_state: AppState,
353 pub router: Arc<Router<usize>>,
354}
355
356pub type AppState = Value;
357
358pub type AppServerState = ServerState<AppState>;
359
360#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
361#[derive(AsRefStr, Debug)]
362enum ApiKey {
363 DOC,
364 METHOD,
365 PATH,
366 #[strum(serialize = "route")]
367 ROUTE,
368}
369
370/// Check api.toml for schema compliance errors
371///
372/// Checks
373/// - Unsupported request method
374/// - Missing DOC string
375/// - Route paths missing or not an array
376pub fn check_api(api: toml::Value) -> bool {
377 let mut error_count = 0;
378 if let Some(api_map) = api[ROUTE.as_ref()].as_table() {
379 let methods = vec!["GET", "POST"];
380 api_map.values().for_each(|entry| {
381 if let Some(paths) = entry[PATH.as_ref()].as_array() {
382 let first_segment = get_first_segment(vs(&paths[0]));
383
384 // Check the method is GET or PUT.
385 let method = vk(entry, METHOD.as_ref());
386 if !methods.contains(&method.as_str()) {
387 error!(
388 "Route: /{}: Unsupported method: {}. Expected one of: {:?}",
389 &first_segment, &method, &methods
390 );
391 error_count += 1;
392 }
393
394 // Check for DOC string.
395 if entry.get(DOC.as_ref()).is_none() || entry[DOC.as_ref()].as_str().is_none() {
396 error!("Route: /{}: Missing DOC string.", &first_segment);
397 error_count += 1;
398 }
399
400 // Every URL segment pattern must have a valid type. For example,
401 // if a segment `:amount` might have type UrlSegment::Integer
402 // indicated by
403 // ":amount" = "Integer"
404 // in the TOML.
405 let paths = entry[PATH.as_ref()]
406 .as_array()
407 .expect("Expecting TOML array.");
408 for path in paths {
409 if path.is_str() {
410 for segment in path.as_str().unwrap().split('/') {
411 if let Some(parameter) = segment.strip_prefix(':') {
412 let stype = vk(entry, segment);
413 if UrlSegment::from_str(&stype).is_err() {
414 error!(
415 "Route /{}: Unrecognized type {} for pattern {}.",
416 &first_segment, stype, ¶meter
417 );
418 error_count += 1;
419 }
420 }
421 }
422 } else {
423 error!(
424 "Route /{}: Found path '{:?}' but expecting a string.",
425 &first_segment, path
426 );
427 }
428 }
429 } else {
430 error!("Expecting TOML array for {:?}.", &entry[PATH.as_ref()]);
431 error_count += 1;
432 }
433 })
434 }
435 error_count == 0
436}
437
438/// Load the web API or panic
439pub fn load_api(path: &Path) -> toml::Value {
440 let messages = read_to_string(path).unwrap_or_else(|_| panic!("Unable to read {:?}.", &path));
441 let api: toml::Value =
442 toml::from_str(&messages).unwrap_or_else(|_| panic!("Unable to parse {:?}.", &path));
443 if !check_api(api.clone()) {
444 panic!("API specification has errors.",);
445 }
446 api
447}
448
449/// Add routes from api.toml to the routefinder instance in tide-disco
450pub fn configure_router(api: &toml::Value) -> Arc<Router<usize>> {
451 let mut router = Router::new();
452 if let Some(api_map) = api[ROUTE.as_ref()].as_table() {
453 let mut index = 0usize;
454 api_map.values().for_each(|entry| {
455 let paths = entry[PATH.as_ref()]
456 .as_array()
457 .expect("Expecting TOML array.");
458 for path in paths {
459 trace!("adding path: {:?}", path);
460 index += 1;
461 router
462 .add(path.as_str().expect("Expecting a path string."), index)
463 .unwrap();
464 }
465 })
466 }
467 Arc::new(router)
468}
469
470/// Return a JSON expression with status 200 indicating the server
471/// is up and running. The JSON expression is normally
472/// {"status": "Available"}
473/// When the server is running but unable to process requests
474/// normally, a response with status 503 and payload {"status":
475/// "unavailable"} should be added.
476pub async fn healthcheck(
477 req: tide::Request<AppServerState>,
478) -> Result<tide::Response, tide::Error> {
479 let status = req.state().health_status.read().await;
480 Ok(tide::Response::builder(StatusCode::OK)
481 .content_type(mime::JSON)
482 .body(tide::prelude::json!({"status": status.as_ref() }))
483 .build())
484}
485
486// Get a string from a toml::Value or panic.
487fn vs(v: &Value) -> &str {
488 v.as_str().unwrap_or_else(|| {
489 panic!(
490 "Expecting TOML string, but found type {}: {:?}",
491 v.type_str(),
492 v
493 )
494 })
495}
496
497// Get a string from an array toml::Value or panic.
498fn vk(v: &Value, key: &str) -> String {
499 if let Some(vv) = v.get(key) {
500 vv.as_str()
501 .unwrap_or_else(|| {
502 panic!(
503 "Expecting TOML string for {}, but found type {}",
504 key,
505 v[key].type_str()
506 )
507 })
508 .to_string()
509 } else {
510 error!("No value for key {}", key);
511 "<missing>".to_string()
512 }
513}
514
515// Given a string delimited by slashes, get the first non-empty
516// segment.
517//
518// For example,
519// - get_first_segment("/foo/bar") -> "foo"
520// - get_first_segment("first/second") -> "first"
521fn get_first_segment(s: &str) -> String {
522 let first_path = s.strip_prefix('/').unwrap_or(s);
523 first_path
524 .split_once('/')
525 .unwrap_or((first_path, ""))
526 .0
527 .to_string()
528}
529
530#[derive(Clone, Debug, EnumString)]
531pub enum UrlSegment {
532 Boolean(Option<bool>),
533 Hexadecimal(Option<u128>),
534 Integer(Option<u128>),
535 TaggedBase64(Option<TaggedBase64>),
536 Literal(Option<String>),
537}
538
539impl UrlSegment {
540 pub fn new(value: &str, vtype: UrlSegment) -> UrlSegment {
541 match vtype {
542 UrlSegment::Boolean(_) => UrlSegment::Boolean(value.parse::<bool>().ok()),
543 UrlSegment::Hexadecimal(_) => {
544 UrlSegment::Hexadecimal(u128::from_str_radix(value, 16).ok())
545 }
546 UrlSegment::Integer(_) => UrlSegment::Integer(value.parse().ok()),
547 UrlSegment::TaggedBase64(_) => {
548 UrlSegment::TaggedBase64(TaggedBase64::parse(value).ok())
549 }
550 UrlSegment::Literal(_) => UrlSegment::Literal(Some(value.to_string())),
551 }
552 }
553 pub fn is_bound(&self) -> bool {
554 match self {
555 UrlSegment::Boolean(v) => v.is_some(),
556 UrlSegment::Hexadecimal(v) => v.is_some(),
557 UrlSegment::Integer(v) => v.is_some(),
558 UrlSegment::TaggedBase64(v) => v.is_some(),
559 UrlSegment::Literal(v) => v.is_some(),
560 }
561 }
562}
563
564/// Get the path to `api.toml`
565pub fn get_api_path(api_toml: &str) -> PathBuf {
566 [env::current_dir().unwrap(), api_toml.into()]
567 .iter()
568 .collect::<PathBuf>()
569}
570
571/// Convert the command line arguments for the config-rs crate
572fn get_cmd_line_map<Args: CommandFactory>() -> config::Environment {
573 config::Environment::default().source(Some({
574 let mut cla = HashMap::new();
575 let matches = Args::command().get_matches();
576 for arg in Args::command().get_arguments() {
577 if let Some(value) = matches.get_one::<String>(arg.get_id().as_str()) {
578 let key = arg.get_id().as_str().replace('-', "_");
579 cla.insert(key, value.to_owned());
580 }
581 }
582 cla
583 }))
584}
585
586/// Compose the path to the application's configuration file
587pub fn compose_config_path(org_dir_name: &str, app_name: &str) -> PathBuf {
588 let mut app_config_path = org_data_path(org_dir_name);
589 app_config_path = app_config_path.join(app_name).join(app_name);
590 app_config_path.set_extension("toml");
591 app_config_path
592}
593
594/// Get the application configuration
595///
596/// Build the configuration from
597/// - Defaults in the tide-disco source
598/// - Defaults passed from the app
599/// - A configuration file from the app
600/// - Command line arguments
601/// - Environment variables
602///
603/// Last one wins.
604///
605/// Environment variables have a prefix of the given app_name in upper case with hyphens converted
606/// to underscores. Hyphens are illegal in environment variables in bash, et.al..
607pub fn compose_settings<Args: CommandFactory>(
608 org_name: &str,
609 app_name: &str,
610 app_defaults: &[(&str, &str)],
611) -> Result<Config, ConfigError> {
612 let app_config_file = &compose_config_path(org_name, app_name);
613 {
614 let app_config = OpenOptions::new()
615 .write(true)
616 .create_new(true)
617 .open(app_config_file);
618 if let Ok(mut app_config) = app_config {
619 write!(
620 app_config,
621 "# {app_name} configuration\n\n\
622 # Note: keys must be lower case.\n\n"
623 )
624 .map_err(|e| ConfigError::Foreign(e.into()))?;
625 for (k, v) in app_defaults {
626 writeln!(app_config, "{k} = \"{v}\"")
627 .map_err(|e| ConfigError::Foreign(e.into()))?;
628 }
629 }
630 // app_config file handle gets closed exiting this scope so
631 // Config can read it.
632 }
633 let env_var_prefix = &app_name.replace('-', "_");
634 let org_config_file = org_data_path(org_name).join("org.toml");
635 // In the config-rs crate, environment variable names are converted to lower case, but keys in
636 // files are not, so if we want environment variables to override file value, we must make file
637 // keys lower case. This is a config-rs bug. See https://github.com/mehcode/config-rs/issues/340
638 let mut builder = Config::builder()
639 .set_default(DiscoKey::base_url.as_ref(), "http://localhost:65535")?
640 .set_default(DiscoKey::disco_toml.as_ref(), "disco.toml")?
641 .set_default(
642 DiscoKey::app_toml.as_ref(),
643 app_api_path(org_name, app_name)
644 .to_str()
645 .expect("Invalid api path"),
646 )?
647 .set_default(DiscoKey::ansi_color.as_ref(), false)?
648 .add_source(config::File::with_name("config/default.toml"))
649 .add_source(config::File::with_name(
650 org_config_file
651 .to_str()
652 .expect("Invalid organization configuration file path"),
653 ))
654 .add_source(config::File::with_name(
655 app_config_file
656 .to_str()
657 .expect("Invalid application configuration file path"),
658 ))
659 .add_source(get_cmd_line_map::<Args>())
660 .add_source(config::Environment::with_prefix(env_var_prefix)); // No hyphens allowed
661 for (k, v) in app_defaults {
662 builder = builder.set_default(*k, *v).expect("Failed to set default");
663 }
664 builder.build()
665}
666
667pub fn init_logging(want_color: bool) {
668 tracing_subscriber::fmt()
669 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
670 .with_ansi(want_color)
671 .init();
672}
673
674pub fn org_data_path(org_name: &str) -> PathBuf {
675 dirs::data_local_dir()
676 .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("./")))
677 .join(org_name)
678}
679
680pub fn app_api_path(org_name: &str, app_name: &str) -> PathBuf {
681 org_data_path(org_name).join(app_name).join("api.toml")
682}
683
684/// Wait for the server to respond to a connection request
685///
686/// This is useful for tests for which it doesn't make sense to send requests until the server has
687/// started.
688pub async fn wait_for_server(url: &Url, retries: u64, sleep_ms: u64) {
689 let dur = Duration::from_millis(sleep_ms);
690 for _ in 0..retries {
691 if reqwest::Client::new()
692 .head(url.clone())
693 .send()
694 .await
695 .is_ok()
696 {
697 return;
698 }
699 sleep(dur).await;
700 }
701 panic!(
702 "Server did not start in {:?} milliseconds",
703 sleep_ms * SERVER_STARTUP_RETRIES
704 );
705}