From 77f5578c4f534ac574c09f2cb76898910eb70ed0 Mon Sep 17 00:00:00 2001 From: jakecooper Date: Wed, 3 Jun 2026 20:25:59 -0700 Subject: [PATCH 1/2] Make GraphQL HTTP timeout configurable via RAILWAY_HTTP_TIMEOUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI's GraphQL client used a hardcoded 30s timeout for every request, with no escape hatch. Long-running mutations — notably `environment new --duplicate` for a multi-service environment with volumes — intermittently exceed 30s and abort with "operation timed out". Raise the default to 90s and allow overriding it with the RAILWAY_HTTP_TIMEOUT env var (in seconds). Invalid values are surfaced as a warning and fall back to the default rather than being silently ignored. Note: this reduces how often the timeout fires but does not make `--duplicate` atomic; a partial failure can still orphan an empty environment. That fix is tracked separately. Closes #923 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/client.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/consts.rs | 6 +++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index 26c28e47b..0e90db938 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use colored::Colorize; use graphql_client::GraphQLQuery; use reqwest::{ Client, @@ -71,12 +72,49 @@ 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::() { + 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( client: &reqwest::Client, url: U, @@ -253,6 +291,31 @@ 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 { diff --git a/src/consts.rs b/src/consts.rs index 9b1c96796..0ee08fe4f 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -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 = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "; From 4d5084bf2c5fe504bf23e62dcc60d035474eed37 Mon Sep 17 00:00:00 2001 From: jakecooper Date: Wed, 3 Jun 2026 20:28:09 -0700 Subject: [PATCH 2/2] Apply cargo fmt Co-Authored-By: Claude Opus 4.8 (1M context) --- src/client.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/client.rs b/src/client.rs index 0e90db938..64774bd53 100644 --- a/src/client.rs +++ b/src/client.rs @@ -86,7 +86,11 @@ impl GQLClient { /// (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_timeout_secs( + std::env::var(consts::RAILWAY_HTTP_TIMEOUT_ENV) + .ok() + .as_deref(), + ) } /// Parse a `RAILWAY_HTTP_TIMEOUT` value into a timeout in seconds. @@ -293,10 +297,7 @@ mod tests { #[test] fn timeout_defaults_when_unset() { - assert_eq!( - parse_timeout_secs(None), - consts::DEFAULT_HTTP_TIMEOUT_SECS - ); + assert_eq!(parse_timeout_secs(None), consts::DEFAULT_HTTP_TIMEOUT_SECS); } #[test]