paperclipai/paperclip · error · RailwayError

railway_invalid_response

railway_invalid_response

Error message

Railway returned an empty response.

What it means

RailwayError (code railway_invalid_response) thrown by boundedResponseText when the HTTP Response has no readable body stream (response.body?.getReader() is undefined). The library needs a streaming body to read and bound Railway API responses; without one it cannot proceed, so it throws this client-side error (no HTTP status appended).

Solutions

  1. Check the response.status first and handle 204/empty bodies before calling body()/boundedResponseText
  2. Ensure the response is read exactly once — do not call response.json()/text() before boundedResponseText
  3. Use Node >= 18 with native fetch (undici) so response.body exposes getReader()
  4. In tests/mocks, stub fetch with a real ReadableStream body instead of a bare Response-like object

Example fix

// before
const text = await boundedResponseText(res, signal);
// after
if (res.status === 204 || !res.body) {
  return ""; // nothing to read
}
const text = await boundedResponseText(res, signal);
Defensive patterns

Strategy: type-guard

Validate before calling

function hasReadableBody(res) {
  return res != null && typeof res.body?.getReader === "function";
}
if (res.status === 204 || !hasReadableBody(res)) return ""; // empty is acceptable

Type guard

function isReadableResponse(res) {
  return typeof res?.body?.getReader === "function";
}

Try / catch

try {
  const text = await boundedResponseText(res, signal);
} catch (e) {
  if (e.code === "railway_invalid_response" && !res.body) {
    logger.warn({ status: res.status }, "Railway returned no body; treating as empty");
    return "";
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling a Railway API wrapper whose fetch returned a response with a null body — e.g. a 204 No Content, a HEAD request, a response already consumed by another reader, or a runtime/polyfill (older undici, service-worker intercepted fetch) that does not expose body streams.

Common situations: Hitting an endpoint that legitimately returns an empty body (204) through the generic body() helper; a proxy/gateway stripping the body; double-reading the response (body already consumed elsewhere); Node < 18 or polyfilled fetch without streaming support; tests stubbing fetch with { ok: true } and no body.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/0d6cbd8095745bf9. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/railway.ts:148

  services: `query PaperclipRailwayServices($projectId:String!,$first:Int!,$after:String) { project(id:$projectId) { id services(first:$first,after:$after) { edges { node { id name projectId } } ${pageInfo} } } }`,
  environments: `query PaperclipRailwayEnvironments($projectId:String!,$first:Int!,$after:String) { project(id:$projectId) { id environments(first:$first,after:$after) { edges { node { id name projectId } } ${pageInfo} } } }`,
  target: `query PaperclipRailwayTarget($projectId:String!,$environmentId:String!,$serviceId:String!) { project(id:$projectId) { id } environment(id:$environmentId) { id projectId } service(id:$serviceId) { id projectId } serviceInstance(environmentId:$environmentId,serviceId:$serviceId) { ${instanceFields} } }`,
  deployment: `query PaperclipRailwayDeployment($deploymentId:String!) { deployment(id:$deploymentId) { ${deploymentFields} instances { id } } }`,
  deployments: `query PaperclipRailwayDeployments($input:DeploymentListInput!,$first:Int!,$after:String) { deployments(input:$input,first:$first,after:$after) { edges { node { ${deploymentFields} } } ${pageInfo} } }`,
  buildLogs: `query PaperclipRailwayBuildLogs($deploymentId:String!,$limit:Int!,$startDate:DateTime,$endDate:DateTime,$filter:String) { buildLogs(deploymentId:$deploymentId,limit:$limit,startDate:$startDate,endDate:$endDate,filter:$filter) { timestamp message severity } }`,
  runtimeLogs: `query PaperclipRailwayRuntimeLogs($deploymentId:String!,$limit:Int!,$startDate:DateTime,$endDate:DateTime,$filter:String) { deploymentLogs(deploymentId:$deploymentId,limit:$limit,startDate:$startDate,endDate:$endDate,filter:$filter) { timestamp message severity } }`,
  redeploy: `mutation PaperclipRailwayRedeploy($deploymentId:String!) { deploymentRedeploy(id:$deploymentId,usePreviousImageTag:true) { id status } }`,
  restart: `mutation PaperclipRailwayRestart($deploymentId:String!) { deploymentRestart(id:$deploymentId) }`,
  rollback: `mutation PaperclipRailwayRollback($deploymentId:String!) { deploymentRollback(id:$deploymentId) }`,
};

function record(value: unknown): Record<string, any> {
  return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
}

async function boundedResponseText(response: Response, signal: AbortSignal): Promise<string> {
  const reader = response.body?.getReader();
  if (!reader) throw new RailwayError("railway_invalid_response", "Railway returned an empty response.");
  const chunks: Uint8Array[] = [];
  let size = 0;
  try {
    for (;;) {
      signal.throwIfAborted();
      const { done, value } = await reader.read();
      if (done) break;
      size += value.byteLength;
      if (size > 1024 * 1024) throw new RailwayError("railway_output_limit", "Railway's response exceeded the limit. Request fewer results or a shorter log interval.");
      chunks.push(value);
    }
  } finally { await reader.cancel().catch(() => {}); }
  return Buffer.concat(chunks).toString("utf8");
}

/** Discover a consented workspace without requesting account-wide API access. */
export async function discoverRailwayWorkspace(options: RailwayClientOptions): Promise<string> {
  const send = (init: RequestInit) => options.request(RAILWAY_MCP_URL, { ...init, redirect: "error", signal: options.signal });

View on GitHub (pinned to 3f1d897a7c)