Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion src/client.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::time::Duration;

use colored::Colorize;
use graphql_client::GraphQLQuery;
use reqwest::{
Client,
Expand Down Expand Up @@ -71,12 +72,53 @@ impl GQLClient {
.danger_accept_invalid_certs(matches!(Configs::get_environment_id(), Environment::Dev))
.user_agent(consts::get_user_agent())
.default_headers(headers)
.timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(resolve_timeout_secs()))
.build()
.unwrap()
}
}

/// Resolve the HTTP request timeout (in seconds).
///
/// Reads the `RAILWAY_HTTP_TIMEOUT` env var as an escape hatch for long-running
/// operations (e.g. duplicating a large environment). Falls back to
/// [`consts::DEFAULT_HTTP_TIMEOUT_SECS`] when unset, and surfaces a warning
/// (rather than silently ignoring) when the value can't be parsed as a positive
/// integer number of seconds.
fn resolve_timeout_secs() -> u64 {
parse_timeout_secs(
std::env::var(consts::RAILWAY_HTTP_TIMEOUT_ENV)
.ok()
.as_deref(),
)
}

/// Parse a `RAILWAY_HTTP_TIMEOUT` value into a timeout in seconds.
///
/// `None` (env var unset) falls back to the default. A value that can't be parsed
/// as a positive integer is surfaced as a warning (rather than silently ignored)
/// and also falls back to the default.
fn parse_timeout_secs(raw: Option<&str>) -> u64 {
let Some(raw) = raw else {
return consts::DEFAULT_HTTP_TIMEOUT_SECS;
};
match raw.trim().parse::<u64>() {
Ok(secs) if secs > 0 => secs,
_ => {
eprintln!(
"{}",
format!(
"Warning: ignoring invalid {}={raw:?}; expected a positive number of seconds, using {}s",
consts::RAILWAY_HTTP_TIMEOUT_ENV,
consts::DEFAULT_HTTP_TIMEOUT_SECS
)
.yellow()
);
consts::DEFAULT_HTTP_TIMEOUT_SECS
}
}
}

pub async fn post_graphql<Q: GraphQLQuery, U: reqwest::IntoUrl>(
client: &reqwest::Client,
url: U,
Expand Down Expand Up @@ -253,6 +295,28 @@ mod tests {
use super::*;
use crate::gql::queries;

#[test]
fn timeout_defaults_when_unset() {
assert_eq!(parse_timeout_secs(None), consts::DEFAULT_HTTP_TIMEOUT_SECS);
}

#[test]
fn timeout_uses_valid_override() {
assert_eq!(parse_timeout_secs(Some("300")), 300);
assert_eq!(parse_timeout_secs(Some(" 90 ")), 90);
}

#[test]
fn timeout_falls_back_on_invalid_values() {
for bad in ["0", "-5", "abc", "12.5", ""] {
assert_eq!(
parse_timeout_secs(Some(bad)),
consts::DEFAULT_HTTP_TIMEOUT_SECS,
"expected fallback for {bad:?}"
);
}
}

fn spawn_graphql_server(
response_for_request: impl FnOnce(String) -> String + Send + 'static,
) -> String {
Expand Down
6 changes: 6 additions & 0 deletions src/consts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,11 @@ pub const RAILWAY_CALLER_ENV: &str = "RAILWAY_CALLER";
pub const RAILWAY_AGENT_SESSION_ENV: &str = "RAILWAY_AGENT_SESSION";
pub const RAILWAY_INSTALL_REQUEST_ID_ENV: &str = "RAILWAY_INSTALL_REQUEST_ID";
pub const RAILWAY_STAGE_UPDATE_ENV: &str = "_RAILWAY_STAGE_UPDATE";
pub const RAILWAY_HTTP_TIMEOUT_ENV: &str = "RAILWAY_HTTP_TIMEOUT";

/// Default HTTP request timeout in seconds, used when `RAILWAY_HTTP_TIMEOUT` is unset.
/// Long-running mutations (e.g. duplicating a multi-service environment with volumes)
/// can exceed the previous 30s cap, so the default is generous and overridable.
pub const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 90;

pub const TICK_STRING: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ";
Loading