feat(cloud-railway): implement real GraphQL API for provision/list/de…#440
feat(cloud-railway): implement real GraphQL API for provision/list/de…#440emil07770 wants to merge 1 commit into
Conversation
…stroy/status Replace stubs with actual Railway GraphQL v2 API calls. All three smoke tests pass; typecheck clean.
Greptile SummaryThis PR converts the Railway cloud adapter from stub implementations to real Railway GraphQL v2 API calls for
Confidence Score: 2/5The adapter will crash at runtime on common Railway API responses — unknown project/service IDs return null data that is immediately dereferenced, and non-JSON HTTP error bodies (rate limits, 5xx) cause opaque parse failures instead of actionable errors. Three independent crash paths exist on the changed code's hot routes (list, status, railwayGql). All are triggered by realistic inputs — an invalid project ID, a deleted service, or a rate-limit response — making these likely to surface in normal use rather than edge cases only. packages/cloud/railway/src/index.ts — the null-data dereferences in list() and status(), and the error-handling order in railwayGql, all need fixes before this is production-safe. Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant Adapter as cloud-railway adapter
participant GQL as railwayGql()
participant Railway as Railway GraphQL v2
Caller->>Adapter: provision(ctx, spec, config)
Adapter->>Adapter: check projectId and GPU kind
Adapter->>GQL: serviceCreate mutation
GQL->>Railway: POST /graphql/v2
Railway-->>GQL: data.serviceCreate
GQL-->>Adapter: data
Adapter-->>Caller: Instance
Caller->>Adapter: list(ctx, config)
Adapter->>GQL: ProjectServices query
GQL->>Railway: POST /graphql/v2
Railway-->>GQL: data.project or null
Note over GQL,Railway: project null causes TypeError
GQL-->>Adapter: data
Adapter-->>Caller: Instance[]
Caller->>Adapter: status(ctx, id)
Adapter->>GQL: ServiceStatus query
GQL->>Railway: POST /graphql/v2
Railway-->>GQL: data.service or null
Note over GQL,Railway: service null causes TypeError
GQL-->>Adapter: data
Adapter-->>Caller: Instance
Caller->>Adapter: destroy(ctx, id)
Adapter->>GQL: ServiceDelete mutation
GQL->>Railway: POST /graphql/v2
Railway-->>GQL: serviceDelete result
GQL-->>Adapter: void
Reviews (1): Last reviewed commit: "feat(cloud-railway): implement real Grap..." | Re-trigger Greptile |
| const json = (await res.json()) as { data?: T; errors?: Array<{ message: string }> }; | ||
| if (json.errors?.length) throw new Error(`Railway: ${json.errors![0]!.message}`); | ||
| if (!res.ok) throw new Error(`Railway HTTP ${res.status}`); | ||
| return json.data as T; |
There was a problem hiding this comment.
res.json() evaluated before HTTP error check
res.json() is called unconditionally on line 29, before !res.ok is checked. When Railway returns a non-JSON error body — e.g. an HTML 429 rate-limit page or a 503 with a plain-text message — the await res.json() call throws a SyntaxError that propagates to the caller as an unintelligible parse failure instead of a meaningful "Railway HTTP 429". Swap the order: check !res.ok with await res.text() first, then parse JSON only when the response looks valid.
| if (!config.projectId) throw new Error('config.projectId is required for Railway provisioning'); | ||
| if (spec.kind === 'gpu') throw new Error('GPU workloads not supported on Railway — use cloud-runpod instead'); | ||
| if (ctx.dryRun) return { id: 'dry-run', kind: spec.kind, status: 'provisioning', createdAt: new Date().toISOString(), hourlyRate: 0, currency: 'USD' }; |
There was a problem hiding this comment.
GPU rejection happens after
dryRun short-circuit
A caller that passes spec.kind === 'gpu' with ctx.dryRun = true will receive a successful Instance response instead of the intended error. The GPU guard should be validated before any branching so that dry-run calls also reject GPU workloads consistently.
| if (!config.projectId) throw new Error('config.projectId is required for Railway provisioning'); | |
| if (spec.kind === 'gpu') throw new Error('GPU workloads not supported on Railway — use cloud-runpod instead'); | |
| if (ctx.dryRun) return { id: 'dry-run', kind: spec.kind, status: 'provisioning', createdAt: new Date().toISOString(), hourlyRate: 0, currency: 'USD' }; | |
| if (!config.projectId) throw new Error('config.projectId is required for Railway provisioning'); | |
| if (spec.kind === 'gpu') throw new Error('GPU workloads not supported on Railway — use cloud-runpod instead'); | |
| if (ctx.dryRun) return { id: 'dry-run', kind: spec.kind, status: 'provisioning' as const, createdAt: new Date().toISOString(), hourlyRate: 0, currency: 'USD' }; |
| }`, | ||
| { id: config.projectId }, | ||
| ); | ||
| return data.project.services.edges.map(e => serviceToInstance(e.node)); |
There was a problem hiding this comment.
data.project can be null, causing a runtime TypeError
Railway's GraphQL API returns { data: { project: null } } (not an errors array) when the project ID is unknown or the token lacks access. Accessing .services.edges on a null project throws a TypeError that surfaces as an unhandled crash rather than a meaningful error. Add a null guard after the API call.
| return data.project.services.edges.map(e => serviceToInstance(e.node)); | |
| if (!data.project) throw new Error(`Railway: project ${config.projectId} not found or not accessible`); | |
| return data.project.services.edges.map(e => serviceToInstance(e.node)); |
| }`, | ||
| { id }, | ||
| ); | ||
| return serviceToInstance(data.service); |
There was a problem hiding this comment.
data.service can be null for an unknown or deleted service ID
Same GraphQL null-data pattern as in list(): querying a non-existent or already-deleted service returns { data: { service: null } }. Passing null to serviceToInstance results in an immediate TypeError on s.id.
| return serviceToInstance(data.service); | |
| if (!data.service) throw new Error(`Railway: service ${id} not found or not accessible`); | |
| return serviceToInstance(data.service); |
| createdAt?: string; | ||
| serviceInstances?: { edges: Array<{ node: { status?: string } }> }; | ||
| }, kind: Instance['kind'] = 'cpu-vps'): Instance { | ||
| const rawStatus = s.serviceInstances?.edges[0]?.node?.status ?? 'ACTIVE'; |
There was a problem hiding this comment.
Empty
serviceInstances defaults status to 'running'
When serviceInstances.edges is empty (e.g. a freshly created service before its first deployment), the ?? 'ACTIVE' fallback maps to 'running'. A service with no instances is not running — 'provisioning' would be a far more accurate default for new services with no recorded instance state.
| const rawStatus = s.serviceInstances?.edges[0]?.node?.status ?? 'ACTIVE'; | |
| const rawStatus = s.serviceInstances?.edges[0]?.node?.status ?? 'BUILDING'; |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Converts the
packages/cloud/railwayadapter from stubs to real Railway GraphQL v2 API calls.Changes
provision()— callsserviceCreateGraphQL mutationlist()— queriesproject.services.edgesviaProjectServicesquerydestroy()— callsserviceDeletemutationstatus()— queriesserviceby ID withserviceInstancesstatusrailwayGql<T>()helper for authenticated GraphQL requestsserviceToInstance()to map Railway status strings toInstance["status"]cloud-runpodTest results
Typecheck: clean