diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6301211 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "cargo" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + target-branch: "develop" diff --git a/.github/workflows/dockerhub_readme.yml b/.github/workflows/dockerhub_readme.yml new file mode 100644 index 0000000..a490342 --- /dev/null +++ b/.github/workflows/dockerhub_readme.yml @@ -0,0 +1,26 @@ +name: Update Docker Hub Readme +on: + push: + branches: + - main +jobs: + PushContainerReadme: + runs-on: ubuntu-latest + + strategy: + matrix: + component: + - rustyspot + + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Sync Readme + uses: lablans/sync-dockerhub-readme@feature/replace-patterns + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD_REQUIRED_FOR_README_SYNC }} + repository: ${{ github.repository }}-${{ matrix.component }} + readme: "./README.md" + replace_pattern: "](./" + replace_with: "](https://raw.githubusercontent.com/${{ github.repository }}/${{ github.ref_name }}/" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..5bcda74 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,125 @@ +name: Build with rust and docker + +on: + push: + pull_request: + schedule: + # Fetch new base image updates every night at 1am + - cron: '0 1 * * *' + +env: + CARGO_TERM_COLOR: always + PROFILE: release + +jobs: + pre-check: + name: Security, License Check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: EmbarkStudios/cargo-deny-action@v1 + + build-rust: + name: Build (Rust) + runs-on: ubuntu-20.04 + + strategy: + matrix: + arch: + - amd64 + - arm64 + + steps: + - name: Set arch ${{ matrix.arch }} + env: + ARCH: ${{ matrix.arch }} + run: | + if [ "${ARCH}" == "arm64" ]; then + echo "rustarch=aarch64-unknown-linux-gnu" >> $GITHUB_ENV + elif [ "${ARCH}" == "amd64" ]; then + echo "rustarch=x86_64-unknown-linux-gnu" >> $GITHUB_ENV + else + exit 1 + fi + if [ "$(dpkg --print-architecture)" != "${ARCH}" ]; then + echo "Cross-compiling to ${ARCH}." + echo "is_cross=true" >> $GITHUB_ENV + else + echo "Natively compiling to ${ARCH}." + echo "is_cross=false" >> $GITHUB_ENV + fi + - name: Set profile ${{ env.PROFILE }} + env: + PROFILE: ${{ env.PROFILE }} + run: | + if [ "${PROFILE}" == "release" ]; then + echo "profilestr=--release" >> $GITHUB_ENV + elif [ "${PROFILE}" == "debug" ]; then + echo "profilestr=" >> $GITHUB_ENV + else + echo "profilestr=--profile $PROFILE" >> $GITHUB_ENV + fi + - uses: actions/checkout@v3 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + target: ${{ env.rustarch }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.arch }}-${{ env.PROFILE }} + prefix-key: "v1-rust" # Increase to invalidate old caches. + - name: Build (${{ matrix.arch }}) + uses: actions-rs/cargo@v1 + with: + use-cross: ${{ env.is_cross }} + command: build + args: --target ${{ env.rustarch }} ${{ env.profilestr }} + - name: Upload Artifact + uses: actions/upload-artifact@v3 + with: + name: binaries-${{ matrix.arch }} + path: | + target/${{ env.rustarch }}/${{ env.PROFILE }}/spot + + # test: + # name: Run tests + # needs: [ build-rust ] + # runs-on: ubuntu-22.04 + + # steps: + # - uses: actions/checkout@v3 + # - uses: actions/download-artifact@v3 + # with: + # name: binaries-amd64 + # path: artifacts/binaries-amd64/ + # - run: ./dev/test ci + + docker: + needs: [ build-rust, pre-check ] + # This workflow defines how a maven package is built, tested and published. + # Visit: https://github.com/samply/github-workflows/blob/develop/.github/workflows/docker-ci.yml, for more information + uses: samply/github-workflows/.github/workflows/docker-ci.yml@main + with: + # The Docker Hub Repository you want eventually push to, e.g samply/share-client + image-name: "samply/rustyspot" + # Define special prefixes for docker tags. They will prefix each images tag. + # image-tag-prefix: "foo" + # Define the build context of your image, typically default '.' will be enough + # build-context: '.' + # Define the Dockerfile of your image, typically default './Dockerfile' will be enough + build-file: './Dockerfile.ci' + # NOTE: This doesn't work currently + # A list of build arguments, passed to the docker build +# build-args: | +# PROFILE=${{ env.PROFILE }} +# COMPONENT=broker + # Define the target platforms of the docker build (default "linux/amd64,linux/arm64/v8") + # build-platforms: "linux/amd64" + # If your actions generate an artifact in a previous build step, you can tell this workflow to download it + artifact-name: '*' + # This passes the secrets from calling workflow to the called workflow + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/rust_security.yml b/.github/workflows/rust_security.yml new file mode 100644 index 0000000..59f1ff0 --- /dev/null +++ b/.github/workflows/rust_security.yml @@ -0,0 +1,11 @@ +on: + schedule: + - cron: '0 3 * * 1' +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions-rs/audit-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 96ef6c0..2c96eb1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -/target +target/ Cargo.lock diff --git a/Cargo.toml b/Cargo.toml index 88b4055..d633674 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ name = "spot" version = "0.1.0" edition = "2021" +license = "Apache-2.0" +documentation = "https://github.com/samply/spot" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -9,6 +11,7 @@ edition = "2021" async-sse = "5.1.0" async-stream = "0.3.4" axum = "0.6.12" +#axum-macros = "0.3.7" clap = { version = "4.2.1", features = ["env", "derive"] } futures = "0.3.28" hyper = "0.14.25" @@ -17,4 +20,12 @@ serde = { version = "1.0.159", features = ["serde_derive"] } serde_json = "1.0.96" tokio = { version = "1.27.0", features = ["full"] } tokio-util = { version = "0.7.7", features = ["io"] } -uuid = { version = "1.3.0", features = ["v4"] } +uuid = { version = "1.3.0", features = ["v4", "serde"] } + +# Logging +tracing = "0.1.37" +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +http = "0.2.9" + +[build-dependencies] +build-data = "0.1.4" diff --git a/Cross.toml b/Cross.toml new file mode 100644 index 0000000..8139e11 --- /dev/null +++ b/Cross.toml @@ -0,0 +1,5 @@ +[target.aarch64-unknown-linux-gnu] +image = "ghcr.io/lablans/cross-test:aarch64-unknown-linux-gnu" + +[target.x86_64-unknown-linux-gnu] +image = "ghcr.io/lablans/cross-test:x86_64-unknown-linux-gnu" diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 0000000..2089f33 --- /dev/null +++ b/Dockerfile.ci @@ -0,0 +1,13 @@ +# This assumes binaries are present, see COPY directive. + +ARG IMGNAME=gcr.io/distroless/cc + +FROM alpine AS chmodder +ARG TARGETARCH +COPY /artifacts/binaries-$TARGETARCH/spot /app/ +RUN chmod +x /app/* + +FROM ${IMGNAME} +COPY --from=chmodder /app/* /usr/local/bin/ +ENTRYPOINT [ "/usr/local/bin/spot" ] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d687dc5 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Samply.Spot + +This is the Rust re-implementation of Samply.Spot. \ No newline at end of file diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..5f41ede --- /dev/null +++ b/build.rs @@ -0,0 +1,26 @@ +use build_data::get_git_dirty; + +/// Outputs a readable version number such as +/// 0.4.0 (if git commit is clean) +/// 0.4.0-SNAPSHOT (if git commit is dirty, should not happen in CI/CD builds) +fn version() -> String { + let version = String::from(env!("CARGO_PKG_VERSION")); + match get_git_dirty().unwrap() { + false => version, + true => { + format!("{}-SNAPSHOT", version) + } + } +} + +fn main() { + build_data::set_GIT_COMMIT_SHORT(); + build_data::set_GIT_DIRTY(); + build_data::set_BUILD_DATE(); + build_data::set_BUILD_TIME(); + build_data::no_debug_rebuilds(); + println!( + "cargo:rustc-env=SAMPLY_USER_AGENT=Samply.Spot/{}", + version() + ); +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..2bfd0b0 --- /dev/null +++ b/deny.toml @@ -0,0 +1,214 @@ +# This template contains all of the possible sections and their default values + +# Note that all fields that take a lint level have these possible values: +# * deny - An error will be produced and the check will fail +# * warn - A warning will be produced, but the check will not fail +# * allow - No warning or error will be produced, though in some cases a note +# will be + +# The values provided in this template are the default values that will be used +# when any section or field is not specified in your own configuration + +# If 1 or more target triples (and optionally, target_features) are specified, +# only the specified targets will be checked when running `cargo deny check`. +# This means, if a particular package is only ever used as a target specific +# dependency, such as, for example, the `nix` crate only being used via the +# `target_family = "unix"` configuration, that only having windows targets in +# this list would mean the nix crate, as well as any of its exclusive +# dependencies not shared by any other crates, would be ignored, as the target +# list here is effectively saying which targets you are building for. +targets = [ + # The triple can be any string, but only the target triples built in to + # rustc (as of 1.40) can be checked against actual config expressions + #{ triple = "x86_64-unknown-linux-musl" }, + # You can also specify which target_features you promise are enabled for a + # particular target. target_features are currently not validated against + # the actual valid features supported by the target architecture. + #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, +] + +# This section is considered when running `cargo deny check advisories` +# More documentation for the advisories section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html +[advisories] +# The path where the advisory database is cloned/fetched into +db-path = "~/.cargo/advisory-db" +# The url(s) of the advisory databases to use +db-urls = ["https://github.com/rustsec/advisory-db"] +# The lint level for security vulnerabilities +vulnerability = "deny" +# The lint level for unmaintained crates +unmaintained = "warn" +# The lint level for crates that have been yanked from their source registry +yanked = "warn" +# The lint level for crates with security notices. Note that as of +# 2019-12-17 there are no security notice advisories in +# https://github.com/rustsec/advisory-db +notice = "warn" +# A list of advisory IDs to ignore. Note that ignored advisories will still +# output a note when they are encountered. +ignore = [ + "RUSTSEC-2020-0071", #used only by build-data as a build dependency +] +# Threshold for security vulnerabilities, any vulnerability with a CVSS score +# lower than the range specified will be ignored. Note that ignored advisories +# will still output a note when they are encountered. +# * None - CVSS Score 0.0 +# * Low - CVSS Score 0.1 - 3.9 +# * Medium - CVSS Score 4.0 - 6.9 +# * High - CVSS Score 7.0 - 8.9 +# * Critical - CVSS Score 9.0 - 10.0 +#severity-threshold = + +# If this is true, then cargo deny will use the git executable to fetch advisory database. +# If this is false, then it uses a built-in git library. +# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. +# See Git Authentication for more information about setting up git authentication. +#git-fetch-with-cli = true + +# This section is considered when running `cargo deny check licenses` +# More documentation for the licenses section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html +[licenses] +# The lint level for crates which do not have a detectable license +unlicensed = "deny" +# List of explicitly allowed licenses +# See https://spdx.org/licenses/ for list of possible licenses +# [possible values: any SPDX 3.11 short identifier (+ optional exception)]. +allow = [ + "MIT", + "Apache-2.0", + "ISC", + "BSD-3-Clause", + "Unicode-DFS-2016", + #"Apache-2.0 WITH LLVM-exception", +] +# List of explicitly disallowed licenses +# See https://spdx.org/licenses/ for list of possible licenses +# [possible values: any SPDX 3.11 short identifier (+ optional exception)]. +deny = [ + #"Nokia", +] +# Lint level for licenses considered copyleft +copyleft = "warn" +# Blanket approval or denial for OSI-approved or FSF Free/Libre licenses +# * both - The license will be approved if it is both OSI-approved *AND* FSF +# * either - The license will be approved if it is either OSI-approved *OR* FSF +# * osi-only - The license will be approved if is OSI-approved *AND NOT* FSF +# * fsf-only - The license will be approved if is FSF *AND NOT* OSI-approved +# * neither - This predicate is ignored and the default lint level is used +allow-osi-fsf-free = "neither" +# Lint level used when no other predicates are matched +# 1. License isn't in the allow or deny lists +# 2. License isn't copyleft +# 3. License isn't OSI/FSF, or allow-osi-fsf-free = "neither" +default = "deny" +# The confidence threshold for detecting a license from license text. +# The higher the value, the more closely the license text must be to the +# canonical license text of a valid SPDX license file. +# [possible values: any between 0.0 and 1.0]. +confidence-threshold = 0.8 +# Allow 1 or more licenses on a per-crate basis, so that particular licenses +# aren't accepted for every possible crate as with the normal allow list +exceptions = [ + # Each entry is the crate and version constraint, and its specific allow + # list + #{ allow = ["Zlib"], name = "adler32", version = "*" }, +] + +# Some crates don't have (easily) machine readable licensing information, +# adding a clarification entry for it allows you to manually specify the +# licensing information +#[[licenses.clarify]] +# The name of the crate the clarification applies to +#name = "ring" +# The optional version constraint for the crate +#version = "*" +# The SPDX expression for the license requirements of the crate +#expression = "MIT AND ISC AND OpenSSL" +# One or more files in the crate's source used as the "source of truth" for +# the license expression. If the contents match, the clarification will be used +# when running the license check, otherwise the clarification will be ignored +# and the crate will be checked normally, which may produce warnings or errors +# depending on the rest of your configuration +#license-files = [ + # Each entry is a crate relative path, and the (opaque) hash of its contents + #{ path = "LICENSE", hash = 0xbd0eed23 } +#] + +[licenses.private] +# If true, ignores workspace crates that aren't published, or are only +# published to private registries. +# To see how to mark a crate as unpublished (to the official registry), +# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. +ignore = false +# One or more private registries that you might publish crates to, if a crate +# is only published to private registries, and ignore is true, the crate will +# not have its license(s) checked +registries = [ + #"https://sekretz.com/registry +] + +# This section is considered when running `cargo deny check bans`. +# More documentation about the 'bans' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html +[bans] +# Lint level for when multiple versions of the same crate are detected +multiple-versions = "warn" +# Lint level for when a crate version requirement is `*` +wildcards = "allow" +# The graph highlighting used when creating dotgraphs for crates +# with multiple versions +# * lowest-version - The path to the lowest versioned duplicate is highlighted +# * simplest-path - The path to the version with the fewest edges is highlighted +# * all - Both lowest-version and simplest-path are used +highlight = "all" +# List of crates that are allowed. Use with care! +allow = [ + #{ name = "ansi_term", version = "=0.11.0" }, +] +# List of crates to deny +deny = [ + # Each entry the name of a crate and a version range. If version is + # not specified, all versions will be matched. + #{ name = "ansi_term", version = "=0.11.0" }, + # + # Wrapper crates can optionally be specified to allow the crate when it + # is a direct dependency of the otherwise banned crate + #{ name = "ansi_term", version = "=0.11.0", wrappers = [] }, +] +# Certain crates/versions that will be skipped when doing duplicate detection. +skip = [ + #{ name = "ansi_term", version = "=0.11.0" }, +] +# Similarly to `skip` allows you to skip certain crates during duplicate +# detection. Unlike skip, it also includes the entire tree of transitive +# dependencies starting at the specified crate, up to a certain depth, which is +# by default infinite +skip-tree = [ + #{ name = "ansi_term", version = "=0.11.0", depth = 20 }, +] + +# This section is considered when running `cargo deny check sources`. +# More documentation about the 'sources' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html +[sources] +# Lint level for what to happen when a crate from a crate registry that is not +# in the allow list is encountered +unknown-registry = "warn" +# Lint level for what to happen when a crate from a git repository that is not +# in the allow list is encountered +unknown-git = "warn" +# List of URLs for allowed crate registries. Defaults to the crates.io index +# if not specified. If it is specified but empty, no registries are allowed. +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +# List of URLs for allowed Git repositories +allow-git = [] + +[sources.allow-org] +# 1 or more github.com organizations to allow git sources for +github = ["samply"] +# 1 or more gitlab.com organizations to allow git sources for +#gitlab = [""] +# 1 or more bitbucket.org organizations to allow git sources for +#bitbucket = [""] diff --git a/src/banner.rs b/src/banner.rs new file mode 100644 index 0000000..969d982 --- /dev/null +++ b/src/banner.rs @@ -0,0 +1,29 @@ +use axum::{http::HeaderValue, response::Response}; +use hyper::header; +use tracing::info; + +pub(crate) fn print_banner() { + let commit = match env!("GIT_DIRTY") { + "false" => { + env!("GIT_COMMIT_SHORT") + } + _ => "SNAPSHOT", + }; + info!( + "🌈 Samply.Spot v{} (built {} {}, {}) starting up ...", + env!("CARGO_PKG_VERSION"), + env!("BUILD_DATE"), + env!("BUILD_TIME"), + commit + ); +} + +pub(crate) async fn set_server_header(mut response: Response) -> Response { + if !response.headers_mut().contains_key(header::SERVER) { + response.headers_mut().insert( + header::SERVER, + HeaderValue::from_static(env!("SAMPLY_USER_AGENT")), + ); + } + response +} diff --git a/src/logger.rs b/src/logger.rs new file mode 100644 index 0000000..c7d3660 --- /dev/null +++ b/src/logger.rs @@ -0,0 +1,30 @@ +use tracing::{debug, dispatcher::SetGlobalDefaultError, Level}; + +#[allow(clippy::if_same_then_else)] // The redundant if-else serves documentation purposes +pub fn init_logger() -> Result<(), SetGlobalDefaultError> { + let subscriber = tracing_subscriber::FmtSubscriber::builder().with_max_level(Level::DEBUG); + + // TODO: Reduce code complexity. + let env_filter = match std::env::var("RUST_LOG") { + Ok(env) if !env.is_empty() => { + if env.contains("hyper=") { + env + } else { + format!("{env},hyper=info") + } + } + _ => { + if cfg!(debug_assertions) { + "info,hyper=info".to_string() + } else { + "info,hyper=info".to_string() + } + } + }; + + let subscriber = subscriber.with_env_filter(env_filter.clone()).finish(); + tracing::subscriber::set_global_default(subscriber)?; + + debug!("Logging initialized with env_filter {env_filter}."); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 7a772fa..63ada46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,11 +3,17 @@ use std::{sync::Arc, convert::Infallible}; use axum::{Router, routing::{get, post}, extract::{Json, State, Path, Query}, response::{Sse, sse::Event, IntoResponse, Response}, http::{HeaderValue, HeaderName}}; use clap::Parser; use futures::{Stream, TryStreamExt, StreamExt}; -use hyper::{HeaderMap, header::{AUTHORIZATION, ACCEPT}, Client, Uri, Request, Method, Body}; +use hyper::{Client, HeaderMap, header::{AUTHORIZATION, ACCEPT}, Uri, Request, Method, Body}; use reqwest::{Url, StatusCode}; use serde::{Serialize, Deserialize}; +use tracing::{debug, trace}; use uuid::Uuid; +use crate::logger::init_logger; + +mod banner; +mod logger; + #[derive(Parser, Clone)] #[clap(author, version, about, long_about = None)] struct Arguments { @@ -79,32 +85,34 @@ struct BeamTask { ttl: String, } -async fn handle_post( +async fn handle_create_beam_task( State(args): State>, Json(query): Json -) -> Result{ - let result = create_beam_task(&args, &query).await?.into_response(); +) -> Result{ + let result = create_beam_task(&args, &query).await?; Ok(result) } -async fn handle_get( +async fn handle_listen_to_beam_tasks( State(args): State>, Path(task_id): Path, Query(wait_count): Query -) -> Result { - let result = listen_beam_results(&args, task_id.to_string(), wait_count).await?.into_response(); +) -> Result { + let result = listen_beam_results(&args, task_id.to_string(), wait_count).await?; Ok(result) } #[tokio::main] async fn main() { + init_logger().expect("Unable to initialize logger."); let args = Arc::new(Arguments::parse()); - println!("Beam URL: {}", &args.beam_url); - println!("Beam App: {}", &args.beam_app); + debug!("Beam URL: {}", &args.beam_url); + debug!("Beam App: {}", &args.beam_app); // TODO: Add check for reachability of beam-proxy let app = Router::new() - .route("/beam", post(handle_post)) - .route("/beam/:task_id", get(handle_get)) + .route("/beam", post(handle_create_beam_task)) + .route("/beam/:task_id", get(handle_listen_to_beam_tasks)) + .layer(axum::middleware::map_response(banner::set_server_header)) .with_state(args); axum::Server::bind(&"0.0.0.0:8080".parse().unwrap()) @@ -113,20 +121,31 @@ async fn main() { .unwrap(); } -async fn create_beam_task(args: &Arguments, query: &LensQuery) -> Result { +async fn create_beam_task(args: &Arguments, query: &LensQuery) -> Result { let client = Client::new(); let url = format!("{}v1/tasks", args.beam_url); let auth_header = generate_auth_header(args); let body = map_lens_to_beam(args, query); - println!("{}", url); - println!("{}", auth_header); - println!("{}", body.id); + trace!(url, auth_header, body.id); + let body = serde_json::to_vec(&body).unwrap(); + let body = hyper::Body::from(body); let req = Request::builder() .method(Method::POST) .uri(url) - .header("Authorization", auth_header) - .body(Body::from(serde_json::from_slice(body))); - client.request(req).await + .header(AUTHORIZATION, auth_header) + .body(body) + .map_err(|e| { + println!("Unable to construct Beam.Proxy query: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Unable to build Beam.Proxy query.") + })?; + let resp = client + .request(req) + .await + .map_err(|e| { + println!("Unable to query Beam.Proxy: {}", e); + (StatusCode::BAD_GATEWAY, "Unable to query Beam.Proxy.") + })?; + Ok(resp) } async fn listen_beam_results(args: &Arguments, task_id: String, wait_count: i32) -> Result>>, (StatusCode, String)> {