use serde::{Deserialize, Serialize};
use std::time::Duration;
const RAILWAY_API_URL: &str = "https://backboard.railway.app/graphql/v2";
const MAX_RETRIES: u32 = 3;
const BASE_DELAY_MS: u64 = 500;
#[derive(Debug, thiserror::Error)]
pub enum RailwayError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("GraphQL error: {0}")]
GraphQL(String),
#[error("missing field in response: {0}")]
MissingField(String),
#[error("HTTP {status}: {body}")]
HttpStatus { status: u16, body: String },
#[error("exhausted {attempts} retries, last error: {source}")]
Exhausted {
attempts: u32,
source: Box<RailwayError>,
},
}
#[derive(Debug, Serialize, Deserialize)]
struct GraphQLRequest {
query: String,
variables: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct GraphQLResponse {
data: Option<serde_json::Value>,
errors: Option<Vec<GraphQLError>>,
}
#[derive(Debug, Deserialize)]
struct GraphQLError {
message: String,
}
pub struct RailwayClient {
http: reqwest::Client,
token: String,
project_id: String,
}
impl RailwayClient {
pub fn new(token: String, project_id: String) -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.expect("failed to create HTTP client");
Self {
http,
token,
project_id,
}
}
pub fn project_id(&self) -> &str {
&self.project_id
}
fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 429 | 502 | 503 | 504)
}
fn is_retryable_error(err: &reqwest::Error) -> bool {
err.is_timeout() || err.is_connect() || err.is_request()
}
fn retry_delay(attempt: u32, retry_after: Option<Duration>) -> Duration {
if let Some(ra) = retry_after {
return ra;
}
let base_ms = BASE_DELAY_MS * 2u64.pow(attempt);
let jitter_range = base_ms / 4;
let jitter = (base_ms as i64)
+ (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos() as i64
% (2 * jitter_range as i64 + 1))
- jitter_range as i64;
Duration::from_millis(jitter.max(100) as u64)
}
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
}
async fn execute(
&self,
query: &str,
variables: serde_json::Value,
) -> Result<serde_json::Value, RailwayError> {
let request = GraphQLRequest {
query: query.to_string(),
variables,
};
let mut last_err: Option<RailwayError> = None;
for attempt in 0..=MAX_RETRIES {
if attempt > 0 {
let delay = Self::retry_delay(attempt - 1, None);
tracing::warn!(
attempt,
delay_ms = delay.as_millis() as u64,
"Retrying Railway API request"
);
tokio::time::sleep(delay).await;
}
let response = match self
.http
.post(RAILWAY_API_URL)
.header("Authorization", format!("Bearer {}", self.token))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
{
Ok(r) => r,
Err(e) => {
if Self::is_retryable_error(&e) && attempt < MAX_RETRIES {
tracing::warn!(attempt, error = %e, "Retryable request error");
last_err = Some(RailwayError::Http(e));
continue;
}
return Err(RailwayError::Http(e));
}
};
let status = response.status();
if !status.is_success() {
if Self::is_retryable_status(status) && attempt < MAX_RETRIES {
let retry_after = Self::parse_retry_after(response.headers());
let body = response.text().await.unwrap_or_default();
tracing::warn!(attempt, status = status.as_u16(), "Retryable HTTP status");
if let Some(ra) = retry_after {
tokio::time::sleep(ra).await;
last_err = Some(RailwayError::HttpStatus {
status: status.as_u16(),
body,
});
continue;
}
last_err = Some(RailwayError::HttpStatus {
status: status.as_u16(),
body,
});
continue;
}
let body = response.text().await.unwrap_or_default();
return Err(RailwayError::HttpStatus {
status: status.as_u16(),
body,
});
}
let gql_response: GraphQLResponse = match response.json().await {
Ok(r) => r,
Err(e) => {
if attempt < MAX_RETRIES {
tracing::warn!(attempt, error = %e, "Failed to parse response JSON");
last_err = Some(RailwayError::Http(e));
continue;
}
return Err(RailwayError::Http(e));
}
};
if let Some(errors) = gql_response.errors {
let messages: Vec<String> = errors.into_iter().map(|e| e.message).collect();
return Err(RailwayError::GraphQL(messages.join("; ")));
}
return gql_response
.data
.ok_or_else(|| RailwayError::MissingField("data".to_string()));
}
Err(RailwayError::Exhausted {
attempts: MAX_RETRIES + 1,
source: Box::new(last_err.unwrap_or_else(|| {
RailwayError::MissingField("unknown error after retries".to_string())
})),
})
}
pub async fn create_service(&self, name: &str) -> Result<String, RailwayError> {
let query = r#"
mutation ServiceCreate($input: ServiceCreateInput!) {
serviceCreate(input: $input) {
id
name
}
}
"#;
let variables = serde_json::json!({
"input": {
"projectId": self.project_id,
"name": name,
}
});
let data = self.execute(query, variables).await?;
data["serviceCreate"]["id"]
.as_str()
.map(String::from)
.ok_or_else(|| RailwayError::MissingField("serviceCreate.id".to_string()))
}
pub async fn get_default_environment(&self) -> Result<String, RailwayError> {
let query = r#"
query Project($id: String!) {
project(id: $id) {
environments {
edges {
node {
id
name
}
}
}
}
}
"#;
let variables = serde_json::json!({ "id": self.project_id });
let data = self.execute(query, variables).await?;
data["project"]["environments"]["edges"]
.as_array()
.and_then(|edges| edges.first())
.and_then(|edge| edge["node"]["id"].as_str())
.map(String::from)
.ok_or_else(|| RailwayError::MissingField("environment id".to_string()))
}
pub async fn set_variables(
&self,
service_id: &str,
environment_id: &str,
variables: serde_json::Value,
) -> Result<(), RailwayError> {
let query = r#"
mutation VariableCollectionUpsert($input: VariableCollectionUpsertInput!) {
variableCollectionUpsert(input: $input)
}
"#;
let input = serde_json::json!({
"input": {
"projectId": self.project_id,
"serviceId": service_id,
"environmentId": environment_id,
"variables": variables,
}
});
self.execute(query, input).await?;
Ok(())
}
pub async fn set_docker_image(
&self,
service_id: &str,
image: &str,
) -> Result<(), RailwayError> {
let query = r#"
mutation ServiceInstanceUpdate($serviceId: String!, $input: ServiceInstanceUpdateInput!) {
serviceInstanceUpdate(serviceId: $serviceId, input: $input)
}
"#;
let variables = serde_json::json!({
"serviceId": service_id,
"input": {
"source": {
"image": image,
}
}
});
self.execute(query, variables).await?;
Ok(())
}
pub async fn create_domain(
&self,
service_id: &str,
environment_id: &str,
) -> Result<String, RailwayError> {
let query = r#"
mutation ServiceDomainCreate($input: ServiceDomainCreateInput!) {
serviceDomainCreate(input: $input) {
id
domain
}
}
"#;
let variables = serde_json::json!({
"input": {
"serviceId": service_id,
"environmentId": environment_id,
}
});
let data = self.execute(query, variables).await?;
data["serviceDomainCreate"]["domain"]
.as_str()
.map(|d| format!("https://{d}"))
.ok_or_else(|| RailwayError::MissingField("serviceDomainCreate.domain".to_string()))
}
pub async fn add_volume(
&self,
service_id: &str,
environment_id: &str,
mount_path: &str,
) -> Result<String, RailwayError> {
let query = r#"
mutation VolumeCreate($input: VolumeCreateInput!) {
volumeCreate(input: $input) {
id
}
}
"#;
let variables = serde_json::json!({
"input": {
"projectId": self.project_id,
"serviceId": service_id,
"environmentId": environment_id,
"mountPath": mount_path,
}
});
let data = self.execute(query, variables).await?;
data["volumeCreate"]["id"]
.as_str()
.map(String::from)
.ok_or_else(|| RailwayError::MissingField("volumeCreate.id".to_string()))
}
pub async fn delete_volume(&self, volume_id: &str) -> Result<(), RailwayError> {
let query = r#"
mutation VolumeDelete($volumeId: String!) {
volumeDelete(volumeId: $volumeId)
}
"#;
let variables = serde_json::json!({ "volumeId": volume_id });
self.execute(query, variables).await?;
Ok(())
}
pub async fn delete_service(&self, service_id: &str) -> Result<(), RailwayError> {
let query = r#"
mutation ServiceDelete($id: String!) {
serviceDelete(id: $id)
}
"#;
let variables = serde_json::json!({ "id": service_id });
self.execute(query, variables).await?;
Ok(())
}
pub async fn update_service_resources(
&self,
service_id: &str,
environment_id: &str,
cpu_limit_millicores: u32,
memory_limit_mb: u32,
) -> Result<(), RailwayError> {
let query = r#"
mutation ServiceInstanceLimitsUpdate($input: ServiceInstanceLimitsUpdateInput!) {
serviceInstanceLimitsUpdate(input: $input)
}
"#;
let vcpus = cpu_limit_millicores as f64 / 1000.0;
let memory_gb = memory_limit_mb as f64 / 1024.0;
let variables = serde_json::json!({
"input": {
"serviceId": service_id,
"environmentId": environment_id,
"vCPUs": vcpus,
"memoryGB": memory_gb,
}
});
self.execute(query, variables).await?;
Ok(())
}
pub async fn connect_repo(
&self,
service_id: &str,
environment_id: &str,
repo: &str,
branch: &str,
) -> Result<(), RailwayError> {
let source_query = r#"
mutation ServiceInstanceUpdate($serviceId: String!, $input: ServiceInstanceUpdateInput!) {
serviceInstanceUpdate(serviceId: $serviceId, input: $input)
}
"#;
let source_vars = serde_json::json!({
"serviceId": service_id,
"input": {
"source": {
"repo": repo,
}
}
});
self.execute(source_query, source_vars).await?;
let trigger_query = r#"
mutation DeploymentTriggerCreate($input: DeploymentTriggerCreateInput!) {
deploymentTriggerCreate(input: $input) { id }
}
"#;
let trigger_vars = serde_json::json!({
"input": {
"serviceId": service_id,
"projectId": self.project_id,
"environmentId": environment_id,
"provider": "github",
"repository": repo,
"branch": branch,
}
});
self.execute(trigger_query, trigger_vars).await?;
Ok(())
}
pub async fn deploy_service(
&self,
service_id: &str,
environment_id: &str,
) -> Result<String, RailwayError> {
let query = r#"
mutation ServiceInstanceDeploy($serviceId: String!, $environmentId: String!) {
serviceInstanceDeploy(serviceId: $serviceId, environmentId: $environmentId)
}
"#;
let variables = serde_json::json!({
"serviceId": service_id,
"environmentId": environment_id,
});
self.execute(query, variables).await?;
Ok("triggered".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_railway_client_creation() {
let client = RailwayClient::new("test-token".to_string(), "test-project".to_string());
assert_eq!(client.project_id(), "test-project");
}
}