From b768390a2bcbf2b88e8ef35977d433cef28325bd Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 26 Aug 2026 13:44:17 -0700 Subject: [PATCH 1/9] feat(fly-io): MachineIdentity protocol for PetSem over /.fly/api FlyIoProtocol still sends Bearer by default. When MachineIdentity is provided on the calling fiber, encode drops Authorization and sets Connection: close so encrypt/sign/decrypt/verify work as the machine over the unix socket without sniffing an empty token. --- packages/fly-io/src/index.ts | 1 + packages/fly-io/src/protocol.ts | 52 ++++++++++++++++++++++++++------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/fly-io/src/index.ts b/packages/fly-io/src/index.ts index d6eb18f7d..f1ea2e61e 100644 --- a/packages/fly-io/src/index.ts +++ b/packages/fly-io/src/index.ts @@ -37,6 +37,7 @@ export { FlyApiProtocol, FlyGraphqlProtocol, SpritesProtocol, + MachineIdentity, type FlyIoOpError, type FlyIoOpContext, type SpritesOpError, diff --git a/packages/fly-io/src/protocol.ts b/packages/fly-io/src/protocol.ts index f8e8166fe..591b7f1d7 100644 --- a/packages/fly-io/src/protocol.ts +++ b/packages/fly-io/src/protocol.ts @@ -18,8 +18,10 @@ * The minted token is cached process-wide, then sent as * `Authorization: Bearer` on sprite ops. There is no `SPRITES_TOKEN`. */ +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import type * as AST from "effect/SchemaAST"; @@ -127,17 +129,45 @@ const resolveFlyCreds = Effect.gen(function* () { return yield* resolve; }); -export const FlyIoProtocol: Layer.Layer = - makeRestProtocol({ - credentials: resolveFlyCreds, - baseUrl: (creds) => creds.apiBaseUrl, - headers: (creds) => ({ - Authorization: `Bearer ${Redacted.value(creds.apiKey)}`, - }), - errorEnvelope: flyErrorEnvelope, - unknownError: ({ message, body }) => - new UnknownFlyIoError({ message, body }), - }); +/** + * Per-call signal that this Machines request is the Fly Machine itself + * (unix socket `/.fly/api`). Org tokens stay on {@link Credentials} for + * GetSecret in the same process; this service only changes headers: + * omit `Authorization`, send `Connection: close`. + */ +export class MachineIdentity extends Context.Service()( + "FlyIoMachineIdentity", +) {} + +const FlyIoProtocolRest: Layer.Layer = makeRestProtocol({ + credentials: resolveFlyCreds, + baseUrl: (creds) => creds.apiBaseUrl, + headers: (creds) => ({ + Authorization: `Bearer ${Redacted.value(creds.apiKey)}`, + }), + errorEnvelope: flyErrorEnvelope, + unknownError: ({ message, body }) => new UnknownFlyIoError({ message, body }), +}); + +export const FlyIoProtocol: Layer.Layer = Layer.effect( + API.Protocol, + Effect.gen(function* () { + const rest = yield* API.Protocol; + return API.Protocol.of({ + encode: (args) => + Effect.gen(function* () { + const request = yield* rest.encode(args); + const machine = yield* Effect.serviceOption(MachineIdentity); + if (Option.isNone(machine)) return request; + return request.pipe( + HttpClientRequest.removeHeader("authorization"), + HttpClientRequest.setHeader("connection", "close"), + ); + }), + decode: rest.decode, + }); + }), +).pipe(Layer.provide(FlyIoProtocolRest)); /** MPG UI-EX REST at `https://api.fly.io` with the Machines bearer token. */ export const FlyApiProtocol: Layer.Layer = From e3b0f42649323aa06ed0b87582b16cf6e7178f93 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 26 Aug 2026 17:08:30 -0700 Subject: [PATCH 2/9] fix(railway): slim projects list query and stop stuck relay pagination The GraphQL `projects` connection was selecting nested workspace/team/members/baseEnvironment at maxDepth 2. That pulled subscriptionType and canvasGroupRefs, failed decode on live rows, and made listOwnedProjects hang under nuke. Also terminate paginateRelay when endCursor does not advance. --- packages/core/src/pagination.ts | 11 +- .../railway/.generated-specs/railway.json | 91 +------------ .../railway/patches/railway/projects.json | 66 ++++++++++ packages/railway/src/services/railway.ts | 120 +----------------- 4 files changed, 78 insertions(+), 210 deletions(-) create mode 100644 packages/railway/patches/railway/projects.json diff --git a/packages/core/src/pagination.ts b/packages/core/src/pagination.ts index a20327c0d..8b2e6280e 100644 --- a/packages/core/src/pagination.ts +++ b/packages/core/src/pagination.ts @@ -366,10 +366,19 @@ export const paginateRelay = < const emptyPage = pagination.items !== undefined && getItems(response, pagination.items).length === 0; + // A connection that keeps returning the same `endCursor` with + // `hasNextPage: true` (Railway `projects` has done this) would + // otherwise paginate forever. + const stuckCursor = + state.cursor !== undefined && + nextCursor !== undefined && + nextCursor !== null && + nextCursor === state.cursor; const nextState: State = { cursor: nextCursor ?? undefined, - done: !hasNext || isTerminalToken(nextCursor) || emptyPage, + done: + !hasNext || isTerminalToken(nextCursor) || emptyPage || stuckCursor, }; return [response, nextState] as const; diff --git a/packages/railway/.generated-specs/railway.json b/packages/railway/.generated-specs/railway.json index 4ca3a4d2c..4cafb3e71 100644 --- a/packages/railway/.generated-specs/railway.json +++ b/packages/railway/.generated-specs/railway.json @@ -18251,7 +18251,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "query projects($after: String, $before: String, $first: Int, $includeDeleted: Boolean, $last: Int, $orderBy: ProjectsOrderBy, $userId: String, $workspaceId: String) {\n projects(after: $after, before: $before, first: $first, includeDeleted: $includeDeleted, last: $last, orderBy: $orderBy, userId: $userId, workspaceId: $workspaceId) {\n edges {\n cursor\n node {\n baseEnvironment {\n canAccess\n canvasGroupRefs\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n unmergedChangesCount\n updatedAt\n }\n baseEnvironmentId\n botPrEnvironments\n createdAt\n deletedAt\n description\n expiredAt\n featureFlags\n focusedPrEnvironments\n id\n isPublic\n isTempProject\n members {\n avatar\n email\n id\n name\n role\n }\n name\n prDeploys\n primaryEnvironmentId\n subscriptionPlanLimit\n subscriptionType\n team {\n adoptionLevel\n avatar\n createdAt\n id\n name\n preferredRegion\n slackChannelId\n supportTierOverride\n updatedAt\n }\n teamId\n updatedAt\n workspace {\n adoptionLevel\n allowDeprecatedRegions\n avatar\n banReason\n createdAt\n discordRole\n has2FAEnforcement\n hasAutomaticDiagnosis\n hasGuardrailsAccess\n hasHipaaBAA\n hasSAML\n id\n name\n plan\n preferredRegion\n redactedDueTo2FAPending\n slackChannelId\n subscriptionModel\n subscriptionPlanLimit\n supportTierOverride\n updatedAt\n usersWithout2FA\n }\n workspaceId\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", + "query": "query projects($after: String, $before: String, $first: Int, $includeDeleted: Boolean, $last: Int, $orderBy: ProjectsOrderBy, $userId: String, $workspaceId: String) {\n projects(after: $after, before: $before, first: $first, includeDeleted: $includeDeleted, last: $last, orderBy: $orderBy, userId: $userId, workspaceId: $workspaceId) {\n edges {\n cursor\n node {\n baseEnvironmentId\n createdAt\n deletedAt\n description\n id\n name\n primaryEnvironmentId\n updatedAt\n workspaceId\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", "operationName": "projects", "type": "query" } @@ -18596,13 +18596,6 @@ "com.railway.api#ProjectsResponseEdgesItemNode": { "type": "structure", "members": { - "baseEnvironment": { - "target": "com.railway.api#ProjectsResponseEdgesItemNodeBaseEnvironment", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, "baseEnvironmentId": { "target": "smithy.api#String", "traits": { @@ -18610,12 +18603,6 @@ "com.railway.graphql#nullable": {} } }, - "botPrEnvironments": { - "target": "smithy.api#Boolean", - "traits": { - "smithy.api#required": {} - } - }, "createdAt": { "target": "smithy.api#String", "traits": { @@ -18636,61 +18623,18 @@ "com.railway.graphql#nullable": {} } }, - "expiredAt": { - "target": "smithy.api#String", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, - "featureFlags": { - "target": "com.railway.api#ProjectsResponseEdgesItemNodeFeatureFlagsList", - "traits": { - "smithy.api#required": {} - } - }, - "focusedPrEnvironments": { - "target": "smithy.api#Boolean", - "traits": { - "smithy.api#required": {} - } - }, "id": { "target": "smithy.api#String", "traits": { "smithy.api#required": {} } }, - "isPublic": { - "target": "smithy.api#Boolean", - "traits": { - "smithy.api#required": {} - } - }, - "isTempProject": { - "target": "smithy.api#Boolean", - "traits": { - "smithy.api#required": {} - } - }, - "members": { - "target": "com.railway.api#ProjectsResponseEdgesItemNodeMembersList", - "traits": { - "smithy.api#required": {} - } - }, "name": { "target": "smithy.api#String", "traits": { "smithy.api#required": {} } }, - "prDeploys": { - "target": "smithy.api#Boolean", - "traits": { - "smithy.api#required": {} - } - }, "primaryEnvironmentId": { "target": "smithy.api#String", "traits": { @@ -18698,45 +18642,12 @@ "com.railway.graphql#nullable": {} } }, - "subscriptionPlanLimit": { - "target": "smithy.api#Document", - "traits": { - "smithy.api#required": {} - } - }, - "subscriptionType": { - "target": "com.railway.api#SubscriptionPlanType", - "traits": { - "smithy.api#required": {} - } - }, - "team": { - "target": "com.railway.api#ProjectsResponseEdgesItemNodeTeam", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, - "teamId": { - "target": "smithy.api#String", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, "updatedAt": { "target": "smithy.api#String", "traits": { "smithy.api#required": {} } }, - "workspace": { - "target": "com.railway.api#ProjectsResponseEdgesItemNodeWorkspace", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, "workspaceId": { "target": "smithy.api#String", "traits": { diff --git a/packages/railway/patches/railway/projects.json b/packages/railway/patches/railway/projects.json new file mode 100644 index 000000000..3b721850b --- /dev/null +++ b/packages/railway/patches/railway/projects.json @@ -0,0 +1,66 @@ +{ + "description": "Select only Project identity scalars on the `projects` connection. Nested workspace/team/members/baseEnvironment at maxDepth 2 pulls subscriptionType and canvasGroupRefs, which fail decode on live rows (same class of bug as auditLogs) and make listOwnedProjects hang under nuke.", + "patches": [ + { + "op": "replace", + "path": "/shapes/com.railway.api#ProjectsRequest/traits/com.railway.graphql#operation/query", + "value": "query projects($after: String, $before: String, $first: Int, $includeDeleted: Boolean, $last: Int, $orderBy: ProjectsOrderBy, $userId: String, $workspaceId: String) {\n projects(after: $after, before: $before, first: $first, includeDeleted: $includeDeleted, last: $last, orderBy: $orderBy, userId: $userId, workspaceId: $workspaceId) {\n edges {\n cursor\n node {\n baseEnvironmentId\n createdAt\n deletedAt\n description\n id\n name\n primaryEnvironmentId\n updatedAt\n workspaceId\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/baseEnvironment" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/botPrEnvironments" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/expiredAt" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/featureFlags" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/focusedPrEnvironments" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/isPublic" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/isTempProject" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/members" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/prDeploys" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/subscriptionPlanLimit" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/subscriptionType" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/team" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/teamId" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectsResponseEdgesItemNode/members/workspace" + } + ] +} diff --git a/packages/railway/src/services/railway.ts b/packages/railway/src/services/railway.ts index 034024f29..f4684013a 100644 --- a/packages/railway/src/services/railway.ts +++ b/packages/railway/src/services/railway.ts @@ -12595,7 +12595,7 @@ export const ProjectsRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "query projects($after: String, $before: String, $first: Int, $includeDeleted: Boolean, $last: Int, $orderBy: ProjectsOrderBy, $userId: String, $workspaceId: String) {\n projects(after: $after, before: $before, first: $first, includeDeleted: $includeDeleted, last: $last, orderBy: $orderBy, userId: $userId, workspaceId: $workspaceId) {\n edges {\n cursor\n node {\n baseEnvironment {\n canAccess\n canvasGroupRefs\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n unmergedChangesCount\n updatedAt\n }\n baseEnvironmentId\n botPrEnvironments\n createdAt\n deletedAt\n description\n expiredAt\n featureFlags\n focusedPrEnvironments\n id\n isPublic\n isTempProject\n members {\n avatar\n email\n id\n name\n role\n }\n name\n prDeploys\n primaryEnvironmentId\n subscriptionPlanLimit\n subscriptionType\n team {\n adoptionLevel\n avatar\n createdAt\n id\n name\n preferredRegion\n slackChannelId\n supportTierOverride\n updatedAt\n }\n teamId\n updatedAt\n workspace {\n adoptionLevel\n allowDeprecatedRegions\n avatar\n banReason\n createdAt\n discordRole\n has2FAEnforcement\n hasAutomaticDiagnosis\n hasGuardrailsAccess\n hasHipaaBAA\n hasSAML\n id\n name\n plan\n preferredRegion\n redactedDueTo2FAPending\n slackChannelId\n subscriptionModel\n subscriptionPlanLimit\n supportTierOverride\n updatedAt\n usersWithout2FA\n }\n workspaceId\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", + "query projects($after: String, $before: String, $first: Int, $includeDeleted: Boolean, $last: Int, $orderBy: ProjectsOrderBy, $userId: String, $workspaceId: String) {\n projects(after: $after, before: $before, first: $first, includeDeleted: $includeDeleted, last: $last, orderBy: $orderBy, userId: $userId, workspaceId: $workspaceId) {\n edges {\n cursor\n node {\n baseEnvironmentId\n createdAt\n deletedAt\n description\n id\n name\n primaryEnvironmentId\n updatedAt\n workspaceId\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", operationName: "projects", type: "query", }), @@ -12604,145 +12604,27 @@ export const ProjectsRequest = /*@__PURE__*/ S.suspend(() => identifier: "ProjectsRequest", }) as any as S.Schema; -export type ProjectsResponseEdgesItemNodeBaseEnvironment = - AdminVolumeInstancesForVolumeResultItemEnvironment; -export const ProjectsResponseEdgesItemNodeBaseEnvironment = - AdminVolumeInstancesForVolumeResultItemEnvironment; - -export type ProjectsResponseEdgesItemNodeFeatureFlagsList = - Array; -export const ProjectsResponseEdgesItemNodeFeatureFlagsList = - /*@__PURE__*/ S.Array( - ActiveProjectFeatureFlag, - ) as any as S.Schema; - -export type ProjectsResponseEdgesItemNodeMembersItem = - InviteCodeUseResponseMembersItem; -export const ProjectsResponseEdgesItemNodeMembersItem = - InviteCodeUseResponseMembersItem; - -export type ProjectsResponseEdgesItemNodeMembersList = - Array; -export const ProjectsResponseEdgesItemNodeMembersList = /*@__PURE__*/ S.Array( - InviteCodeUseResponseMembersItem, -) as any as S.Schema; - -export type ProjectsResponseEdgesItemNodeTeam = InviteCodeUseResponseTeam; -export const ProjectsResponseEdgesItemNodeTeam = InviteCodeUseResponseTeam; - -export type ProjectsResponseEdgesItemNodeWorkspaceUsersWithout2FAList = - Array; -export const ProjectsResponseEdgesItemNodeWorkspaceUsersWithout2FAList = - /*@__PURE__*/ S.Array( - S.String, - ) as any as S.Schema; - -export interface ProjectsResponseEdgesItemNodeWorkspace { - adoptionLevel: number; - allowDeprecatedRegions: boolean | null; - avatar: string | null; - banReason: string | null; - createdAt: string; - discordRole: string | null; - has2FAEnforcement: boolean; - hasAutomaticDiagnosis: boolean; - hasGuardrailsAccess: boolean; - hasHipaaBAA: boolean; - hasSAML: boolean; - id: string; - name: string; - plan: Plan; - preferredRegion: string | null; - redactedDueTo2FAPending: boolean; - slackChannelId: string | null; - subscriptionModel: SubscriptionModel; - subscriptionPlanLimit: unknown | null; - supportTierOverride: SupportTierOverride | null; - updatedAt: string; - usersWithout2FA: ProjectsResponseEdgesItemNodeWorkspaceUsersWithout2FAList; -} -export const ProjectsResponseEdgesItemNodeWorkspace = /*@__PURE__*/ S.suspend( - () => - S.Struct({ - adoptionLevel: S.Number, - allowDeprecatedRegions: S.NullOr(S.Boolean), - avatar: S.NullOr(S.String), - banReason: S.NullOr(S.String), - createdAt: S.String, - discordRole: S.NullOr(S.String), - has2FAEnforcement: S.Boolean, - hasAutomaticDiagnosis: S.Boolean, - hasGuardrailsAccess: S.Boolean, - hasHipaaBAA: S.Boolean, - hasSAML: S.Boolean, - id: S.String, - name: S.String, - plan: Plan, - preferredRegion: S.NullOr(S.String), - redactedDueTo2FAPending: S.Boolean, - slackChannelId: S.NullOr(S.String), - subscriptionModel: SubscriptionModel, - subscriptionPlanLimit: S.NullOr(S.Unknown), - supportTierOverride: S.NullOr(SupportTierOverride), - updatedAt: S.String, - usersWithout2FA: - ProjectsResponseEdgesItemNodeWorkspaceUsersWithout2FAList, - }), -).annotate({ - identifier: "ProjectsResponseEdgesItemNodeWorkspace", -}) as any as S.Schema; - export interface ProjectsResponseEdgesItemNode { - baseEnvironment: AdminVolumeInstancesForVolumeResultItemEnvironment | null; baseEnvironmentId: string | null; - botPrEnvironments: boolean; createdAt: string; deletedAt: string | null; description: string | null; - expiredAt: string | null; - featureFlags: ProjectsResponseEdgesItemNodeFeatureFlagsList; - focusedPrEnvironments: boolean; id: string; - isPublic: boolean; - isTempProject: boolean; - members: ProjectsResponseEdgesItemNodeMembersList; name: string; - prDeploys: boolean; primaryEnvironmentId: string | null; - subscriptionPlanLimit: unknown; - subscriptionType: SubscriptionPlanType; - team: InviteCodeUseResponseTeam | null; - teamId: string | null; updatedAt: string; - workspace: ProjectsResponseEdgesItemNodeWorkspace | null; workspaceId: string | null; } export const ProjectsResponseEdgesItemNode = /*@__PURE__*/ S.suspend(() => S.Struct({ - baseEnvironment: S.NullOr( - AdminVolumeInstancesForVolumeResultItemEnvironment, - ), baseEnvironmentId: S.NullOr(S.String), - botPrEnvironments: S.Boolean, createdAt: S.String, deletedAt: S.NullOr(S.String), description: S.NullOr(S.String), - expiredAt: S.NullOr(S.String), - featureFlags: ProjectsResponseEdgesItemNodeFeatureFlagsList, - focusedPrEnvironments: S.Boolean, id: S.String, - isPublic: S.Boolean, - isTempProject: S.Boolean, - members: ProjectsResponseEdgesItemNodeMembersList, name: S.String, - prDeploys: S.Boolean, primaryEnvironmentId: S.NullOr(S.String), - subscriptionPlanLimit: S.Unknown, - subscriptionType: SubscriptionPlanType, - team: S.NullOr(InviteCodeUseResponseTeam), - teamId: S.NullOr(S.String), updatedAt: S.String, - workspace: S.NullOr(ProjectsResponseEdgesItemNodeWorkspace), workspaceId: S.NullOr(S.String), }), ).annotate({ From 5f27657f3d9b934730fdb8ba51510eea80b0260b Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 26 Aug 2026 20:12:30 -0700 Subject: [PATCH 3/9] fix(railway): slim project get and cap concurrent GraphQL Nuke scans ~20 resource list()s that each call project({id}) and environments. The default maxDepth-2 selection pulled workspace/team/ members/baseEnvironment and canvasGroupRefs, then distilled retried 429s silently for minutes. project({id}) now selects identity plus services/buckets/groups. environments list is identity scalars. HttpClient GraphQlHttpGate caps in-flight GraphQL POSTs at 2 (DISTILLED_RAILWAY_GRAPHQL_CONCURRENCY). --- .../railway/.generated-specs/railway.json | 125 +---------------- .../railway/patches/railway/environments.json | 30 ++++ .../patches/railway/projectServicesSlim.json | 66 +++++++++ packages/railway/src/index.ts | 2 + packages/railway/src/protocol.ts | 51 ++++++- packages/railway/src/services/railway.ts | 128 +----------------- 6 files changed, 152 insertions(+), 250 deletions(-) create mode 100644 packages/railway/patches/railway/environments.json create mode 100644 packages/railway/patches/railway/projectServicesSlim.json diff --git a/packages/railway/.generated-specs/railway.json b/packages/railway/.generated-specs/railway.json index 4cafb3e71..877c94f47 100644 --- a/packages/railway/.generated-specs/railway.json +++ b/packages/railway/.generated-specs/railway.json @@ -8619,7 +8619,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n canAccess\n canvasGroupRefs\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n meta {\n baseBranch\n branch\n latestSuccessfulGitHubDeploymentId\n prCommentId\n prNumber\n prRepo\n prTitle\n skippedResourceIds\n }\n name\n projectId\n unmergedChangesCount\n updatedAt\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", + "query": "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", "operationName": "environments", "type": "query" } @@ -8689,24 +8689,6 @@ "com.railway.api#EnvironmentsResponseEdgesItemNode": { "type": "structure", "members": { - "canAccess": { - "target": "smithy.api#Boolean", - "traits": { - "smithy.api#required": {} - } - }, - "canvasGroupRefs": { - "target": "smithy.api#Document", - "traits": { - "smithy.api#required": {} - } - }, - "configEtag": { - "target": "smithy.api#String", - "traits": { - "smithy.api#required": {} - } - }, "createdAt": { "target": "smithy.api#String", "traits": { @@ -8732,13 +8714,6 @@ "smithy.api#required": {} } }, - "meta": { - "target": "com.railway.api#EnvironmentsResponseEdgesItemNodeMeta", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, "name": { "target": "smithy.api#String", "traits": { @@ -8751,13 +8726,6 @@ "smithy.api#required": {} } }, - "unmergedChangesCount": { - "target": "smithy.api#Integer", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, "updatedAt": { "target": "smithy.api#String", "traits": { @@ -15778,7 +15746,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "query project($id: String!) {\n project(id: $id) {\n baseEnvironment {\n canAccess\n canvasGroupRefs\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n unmergedChangesCount\n updatedAt\n }\n baseEnvironmentId\n botPrEnvironments\n createdAt\n deletedAt\n description\n expiredAt\n featureFlags\n focusedPrEnvironments\n id\n isPublic\n isTempProject\n members {\n avatar\n email\n id\n name\n role\n }\n name\n prDeploys\n primaryEnvironmentId\n subscriptionPlanLimit\n subscriptionType\n team {\n adoptionLevel\n avatar\n createdAt\n id\n name\n preferredRegion\n slackChannelId\n supportTierOverride\n updatedAt\n }\n teamId\n updatedAt\n workspace {\n adoptionLevel\n allowDeprecatedRegions\n avatar\n banReason\n createdAt\n discordRole\n has2FAEnforcement\n hasAutomaticDiagnosis\n hasGuardrailsAccess\n hasHipaaBAA\n hasSAML\n id\n name\n plan\n preferredRegion\n redactedDueTo2FAPending\n slackChannelId\n subscriptionModel\n subscriptionPlanLimit\n supportTierOverride\n updatedAt\n usersWithout2FA\n }\n workspaceId\n services(first: 50) {\n edges {\n node {\n createdAt\n deletedAt\n groupId\n id\n name\n projectId\n }\n }\n }\n buckets(first: 50) {\n edges {\n node {\n createdAt\n groupId\n id\n name\n projectId\n updatedAt\n }\n }\n }\n groups(first: 50) {\n edges {\n node {\n color\n groupId\n icon\n id\n isCollapsed\n name\n projectId\n }\n }\n }\n }\n}", + "query": "query project($id: String!) {\n project(id: $id) {\n baseEnvironmentId\n createdAt\n deletedAt\n description\n id\n name\n primaryEnvironmentId\n updatedAt\n workspaceId\n services(first: 50) {\n edges {\n node {\n createdAt\n deletedAt\n groupId\n id\n name\n projectId\n }\n }\n }\n buckets(first: 50) {\n edges {\n node {\n createdAt\n groupId\n id\n name\n projectId\n updatedAt\n }\n }\n }\n groups(first: 50) {\n edges {\n node {\n color\n groupId\n icon\n id\n isCollapsed\n name\n projectId\n }\n }\n }\n }\n}", "operationName": "project", "type": "query" } @@ -16123,13 +16091,6 @@ "com.railway.api#ProjectResponse": { "type": "structure", "members": { - "baseEnvironment": { - "target": "com.railway.api#ProjectResponseBaseEnvironment", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, "baseEnvironmentId": { "target": "smithy.api#String", "traits": { @@ -16137,12 +16098,6 @@ "com.railway.graphql#nullable": {} } }, - "botPrEnvironments": { - "target": "smithy.api#Boolean", - "traits": { - "smithy.api#required": {} - } - }, "createdAt": { "target": "smithy.api#String", "traits": { @@ -16163,61 +16118,18 @@ "com.railway.graphql#nullable": {} } }, - "expiredAt": { - "target": "smithy.api#String", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, - "featureFlags": { - "target": "com.railway.api#ProjectResponseFeatureFlagsList", - "traits": { - "smithy.api#required": {} - } - }, - "focusedPrEnvironments": { - "target": "smithy.api#Boolean", - "traits": { - "smithy.api#required": {} - } - }, "id": { "target": "smithy.api#String", "traits": { "smithy.api#required": {} } }, - "isPublic": { - "target": "smithy.api#Boolean", - "traits": { - "smithy.api#required": {} - } - }, - "isTempProject": { - "target": "smithy.api#Boolean", - "traits": { - "smithy.api#required": {} - } - }, - "members": { - "target": "com.railway.api#ProjectResponseMembersList", - "traits": { - "smithy.api#required": {} - } - }, "name": { "target": "smithy.api#String", "traits": { "smithy.api#required": {} } }, - "prDeploys": { - "target": "smithy.api#Boolean", - "traits": { - "smithy.api#required": {} - } - }, "primaryEnvironmentId": { "target": "smithy.api#String", "traits": { @@ -16225,45 +16137,12 @@ "com.railway.graphql#nullable": {} } }, - "subscriptionPlanLimit": { - "target": "smithy.api#Document", - "traits": { - "smithy.api#required": {} - } - }, - "subscriptionType": { - "target": "com.railway.api#SubscriptionPlanType", - "traits": { - "smithy.api#required": {} - } - }, - "team": { - "target": "com.railway.api#ProjectResponseTeam", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, - "teamId": { - "target": "smithy.api#String", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, "updatedAt": { "target": "smithy.api#String", "traits": { "smithy.api#required": {} } }, - "workspace": { - "target": "com.railway.api#ProjectResponseWorkspace", - "traits": { - "smithy.api#required": {}, - "com.railway.graphql#nullable": {} - } - }, "workspaceId": { "target": "smithy.api#String", "traits": { diff --git a/packages/railway/patches/railway/environments.json b/packages/railway/patches/railway/environments.json new file mode 100644 index 000000000..0d881336c --- /dev/null +++ b/packages/railway/patches/railway/environments.json @@ -0,0 +1,30 @@ +{ + "description": "Select only Environment identity scalars on the `environments` connection. maxDepth 2 pulls canvasGroupRefs/meta; nuke's Volume/Variable/Group/CloudAgent list()s each walk every owned project's environments.", + "patches": [ + { + "op": "replace", + "path": "/shapes/com.railway.api#EnvironmentsRequest/traits/com.railway.graphql#operation/query", + "value": "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#EnvironmentsResponseEdgesItemNode/members/canAccess" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#EnvironmentsResponseEdgesItemNode/members/canvasGroupRefs" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#EnvironmentsResponseEdgesItemNode/members/configEtag" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#EnvironmentsResponseEdgesItemNode/members/meta" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#EnvironmentsResponseEdgesItemNode/members/unmergedChangesCount" + } + ] +} diff --git a/packages/railway/patches/railway/projectServicesSlim.json b/packages/railway/patches/railway/projectServicesSlim.json new file mode 100644 index 000000000..0823d71e8 --- /dev/null +++ b/packages/railway/patches/railway/projectServicesSlim.json @@ -0,0 +1,66 @@ +{ + "description": "Slim project({id}) to identity scalars plus services/buckets/groups. The default maxDepth-2 selection (workspace/team/members/baseEnvironment) is why nuke's 16 parallel list()s stampede GraphQL. Alchemy only reads workspaceId, environment ids, and those three connections.", + "patches": [ + { + "op": "replace", + "path": "/shapes/com.railway.api#ProjectRequest/traits/com.railway.graphql#operation/query", + "value": "query project($id: String!) {\n project(id: $id) {\n baseEnvironmentId\n createdAt\n deletedAt\n description\n id\n name\n primaryEnvironmentId\n updatedAt\n workspaceId\n services(first: 50) {\n edges {\n node {\n createdAt\n deletedAt\n groupId\n id\n name\n projectId\n }\n }\n }\n buckets(first: 50) {\n edges {\n node {\n createdAt\n groupId\n id\n name\n projectId\n updatedAt\n }\n }\n }\n groups(first: 50) {\n edges {\n node {\n color\n groupId\n icon\n id\n isCollapsed\n name\n projectId\n }\n }\n }\n }\n}" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/baseEnvironment" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/botPrEnvironments" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/expiredAt" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/featureFlags" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/focusedPrEnvironments" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/isPublic" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/isTempProject" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/members" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/prDeploys" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/subscriptionPlanLimit" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/subscriptionType" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/team" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/teamId" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#ProjectResponse/members/workspace" + } + ] +} diff --git a/packages/railway/src/index.ts b/packages/railway/src/index.ts index 90c7a14cf..2011957f1 100644 --- a/packages/railway/src/index.ts +++ b/packages/railway/src/index.ts @@ -26,6 +26,8 @@ export * from "./errors.ts"; export * as T from "./traits.ts"; export { RailwayGraphqlProtocol, + GraphQlHttpGate, + gateHttpClient, type RailwayOpError, type RailwayOpContext, } from "./protocol.ts"; diff --git a/packages/railway/src/protocol.ts b/packages/railway/src/protocol.ts index 3f9328766..f47a31e64 100644 --- a/packages/railway/src/protocol.ts +++ b/packages/railway/src/protocol.ts @@ -28,8 +28,9 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import type * as AST from "effect/SchemaAST"; -import type * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClient from "effect/unstable/http/HttpClient"; import type * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -370,3 +371,51 @@ export const RailwayGraphqlProtocol: Layer.Layer = Layer.succeed( decode, }), ); + +/** + * Max in-flight GraphQL POSTs. Railway's public API 429s hard when a + * consumer (alchemy nuke scans ~20 resource `list()`s) fans out. Override + * with `DISTILLED_RAILWAY_GRAPHQL_CONCURRENCY`. + */ +const graphqlConcurrency = (): number => { + const raw = process.env.DISTILLED_RAILWAY_GRAPHQL_CONCURRENCY; + if (raw === undefined || raw === "") return 2; + const n = Number(raw); + if (!Number.isFinite(n) || n < 1) return 2; + return Math.trunc(n); +}; + +const graphqlSemaphore = Semaphore.makeUnsafe(graphqlConcurrency()); + +/** + * Wrap an {@link HttpClient.HttpClient} so GraphQL calls take a global + * permit. Provide over `FetchHttpClient.layer` so every Railway operation + * in the process shares the cap. + */ +export const gateHttpClient = ( + client: HttpClient.HttpClient, +): HttpClient.HttpClient => + HttpClient.transform(client, (effect) => + graphqlSemaphore.withPermits(1)(effect), + ); + +/** + * Replaces `HttpClient.HttpClient` with a gated wrapper of the client + * already in context. + * + * @example + * ```ts + * GraphQlHttpGate.pipe(Layer.provide(FetchHttpClient.layer)) + * ``` + */ +export const GraphQlHttpGate: Layer.Layer< + HttpClient.HttpClient, + never, + HttpClient.HttpClient +> = Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const inner = yield* HttpClient.HttpClient; + return gateHttpClient(inner); + }), +); diff --git a/packages/railway/src/services/railway.ts b/packages/railway/src/services/railway.ts index f4684013a..310757fea 100644 --- a/packages/railway/src/services/railway.ts +++ b/packages/railway/src/services/railway.ts @@ -6025,7 +6025,7 @@ export const EnvironmentsRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n canAccess\n canvasGroupRefs\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n meta {\n baseBranch\n branch\n latestSuccessfulGitHubDeploymentId\n prCommentId\n prNumber\n prRepo\n prTitle\n skippedResourceIds\n }\n name\n projectId\n unmergedChangesCount\n updatedAt\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", + "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", operationName: "environments", type: "query", }), @@ -6034,36 +6034,23 @@ export const EnvironmentsRequest = /*@__PURE__*/ S.suspend(() => identifier: "EnvironmentsRequest", }) as any as S.Schema; -export type EnvironmentsResponseEdgesItemNodeMeta = EnvironmentResponseMeta; -export const EnvironmentsResponseEdgesItemNodeMeta = EnvironmentResponseMeta; - export interface EnvironmentsResponseEdgesItemNode { - canAccess: boolean; - canvasGroupRefs: unknown; - configEtag: string; createdAt: string; deletedAt: string | null; id: string; isEphemeral: boolean; - meta: EnvironmentResponseMeta | null; name: string; projectId: string; - unmergedChangesCount: number | null; updatedAt: string; } export const EnvironmentsResponseEdgesItemNode = /*@__PURE__*/ S.suspend(() => S.Struct({ - canAccess: S.Boolean, - canvasGroupRefs: S.Unknown, - configEtag: S.String, createdAt: S.String, deletedAt: S.NullOr(S.String), id: S.String, isEphemeral: S.Boolean, - meta: S.NullOr(EnvironmentResponseMeta), name: S.String, projectId: S.String, - unmergedChangesCount: S.NullOr(S.Number), updatedAt: S.String, }), ).annotate({ @@ -10941,94 +10928,13 @@ export const ProjectRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "query project($id: String!) {\n project(id: $id) {\n baseEnvironment {\n canAccess\n canvasGroupRefs\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n unmergedChangesCount\n updatedAt\n }\n baseEnvironmentId\n botPrEnvironments\n createdAt\n deletedAt\n description\n expiredAt\n featureFlags\n focusedPrEnvironments\n id\n isPublic\n isTempProject\n members {\n avatar\n email\n id\n name\n role\n }\n name\n prDeploys\n primaryEnvironmentId\n subscriptionPlanLimit\n subscriptionType\n team {\n adoptionLevel\n avatar\n createdAt\n id\n name\n preferredRegion\n slackChannelId\n supportTierOverride\n updatedAt\n }\n teamId\n updatedAt\n workspace {\n adoptionLevel\n allowDeprecatedRegions\n avatar\n banReason\n createdAt\n discordRole\n has2FAEnforcement\n hasAutomaticDiagnosis\n hasGuardrailsAccess\n hasHipaaBAA\n hasSAML\n id\n name\n plan\n preferredRegion\n redactedDueTo2FAPending\n slackChannelId\n subscriptionModel\n subscriptionPlanLimit\n supportTierOverride\n updatedAt\n usersWithout2FA\n }\n workspaceId\n services(first: 50) {\n edges {\n node {\n createdAt\n deletedAt\n groupId\n id\n name\n projectId\n }\n }\n }\n buckets(first: 50) {\n edges {\n node {\n createdAt\n groupId\n id\n name\n projectId\n updatedAt\n }\n }\n }\n groups(first: 50) {\n edges {\n node {\n color\n groupId\n icon\n id\n isCollapsed\n name\n projectId\n }\n }\n }\n }\n}", + "query project($id: String!) {\n project(id: $id) {\n baseEnvironmentId\n createdAt\n deletedAt\n description\n id\n name\n primaryEnvironmentId\n updatedAt\n workspaceId\n services(first: 50) {\n edges {\n node {\n createdAt\n deletedAt\n groupId\n id\n name\n projectId\n }\n }\n }\n buckets(first: 50) {\n edges {\n node {\n createdAt\n groupId\n id\n name\n projectId\n updatedAt\n }\n }\n }\n groups(first: 50) {\n edges {\n node {\n color\n groupId\n icon\n id\n isCollapsed\n name\n projectId\n }\n }\n }\n }\n}", operationName: "project", type: "query", }), ), ).annotate({ identifier: "ProjectRequest" }) as any as S.Schema; -export type ProjectResponseBaseEnvironment = - AdminVolumeInstancesForVolumeResultItemEnvironment; -export const ProjectResponseBaseEnvironment = - AdminVolumeInstancesForVolumeResultItemEnvironment; - -export type ProjectResponseFeatureFlagsList = Array; -export const ProjectResponseFeatureFlagsList = /*@__PURE__*/ S.Array( - ActiveProjectFeatureFlag, -) as any as S.Schema; - -export type ProjectResponseMembersItem = InviteCodeUseResponseMembersItem; -export const ProjectResponseMembersItem = InviteCodeUseResponseMembersItem; - -export type ProjectResponseMembersList = - Array; -export const ProjectResponseMembersList = /*@__PURE__*/ S.Array( - InviteCodeUseResponseMembersItem, -) as any as S.Schema; - -export type ProjectResponseTeam = InviteCodeUseResponseTeam; -export const ProjectResponseTeam = InviteCodeUseResponseTeam; - -export type ProjectResponseWorkspaceUsersWithout2FAList = Array; -export const ProjectResponseWorkspaceUsersWithout2FAList = - /*@__PURE__*/ S.Array( - S.String, - ) as any as S.Schema; - -export interface ProjectResponseWorkspace { - adoptionLevel: number; - allowDeprecatedRegions: boolean | null; - avatar: string | null; - banReason: string | null; - createdAt: string; - discordRole: string | null; - has2FAEnforcement: boolean; - hasAutomaticDiagnosis: boolean; - hasGuardrailsAccess: boolean; - hasHipaaBAA: boolean; - hasSAML: boolean; - id: string; - name: string; - plan: Plan; - preferredRegion: string | null; - redactedDueTo2FAPending: boolean; - slackChannelId: string | null; - subscriptionModel: SubscriptionModel; - subscriptionPlanLimit: unknown | null; - supportTierOverride: SupportTierOverride | null; - updatedAt: string; - usersWithout2FA: ProjectResponseWorkspaceUsersWithout2FAList; -} -export const ProjectResponseWorkspace = /*@__PURE__*/ S.suspend(() => - S.Struct({ - adoptionLevel: S.Number, - allowDeprecatedRegions: S.NullOr(S.Boolean), - avatar: S.NullOr(S.String), - banReason: S.NullOr(S.String), - createdAt: S.String, - discordRole: S.NullOr(S.String), - has2FAEnforcement: S.Boolean, - hasAutomaticDiagnosis: S.Boolean, - hasGuardrailsAccess: S.Boolean, - hasHipaaBAA: S.Boolean, - hasSAML: S.Boolean, - id: S.String, - name: S.String, - plan: Plan, - preferredRegion: S.NullOr(S.String), - redactedDueTo2FAPending: S.Boolean, - slackChannelId: S.NullOr(S.String), - subscriptionModel: SubscriptionModel, - subscriptionPlanLimit: S.NullOr(S.Unknown), - supportTierOverride: S.NullOr(SupportTierOverride), - updatedAt: S.String, - usersWithout2FA: ProjectResponseWorkspaceUsersWithout2FAList, - }), -).annotate({ - identifier: "ProjectResponseWorkspace", -}) as any as S.Schema; - export interface ProjectResponseServicesEdgesItemNode { createdAt: string; deletedAt: string | null; @@ -11181,28 +11087,14 @@ export const ProjectResponseGroups = /*@__PURE__*/ S.suspend(() => /** Selection set for `project` (unwrapped from the GraphQL `data` envelope). */ export interface ProjectResponse { - baseEnvironment: AdminVolumeInstancesForVolumeResultItemEnvironment | null; baseEnvironmentId: string | null; - botPrEnvironments: boolean; createdAt: string; deletedAt: string | null; description: string | null; - expiredAt: string | null; - featureFlags: ProjectResponseFeatureFlagsList; - focusedPrEnvironments: boolean; id: string; - isPublic: boolean; - isTempProject: boolean; - members: ProjectResponseMembersList; name: string; - prDeploys: boolean; primaryEnvironmentId: string | null; - subscriptionPlanLimit: unknown; - subscriptionType: SubscriptionPlanType; - team: InviteCodeUseResponseTeam | null; - teamId: string | null; updatedAt: string; - workspace: ProjectResponseWorkspace | null; workspaceId: string | null; services: ProjectResponseServices; buckets: ProjectResponseBuckets; @@ -11210,30 +11102,14 @@ export interface ProjectResponse { } export const ProjectResponse = /*@__PURE__*/ S.suspend(() => S.Struct({ - baseEnvironment: S.NullOr( - AdminVolumeInstancesForVolumeResultItemEnvironment, - ), baseEnvironmentId: S.NullOr(S.String), - botPrEnvironments: S.Boolean, createdAt: S.String, deletedAt: S.NullOr(S.String), description: S.NullOr(S.String), - expiredAt: S.NullOr(S.String), - featureFlags: ProjectResponseFeatureFlagsList, - focusedPrEnvironments: S.Boolean, id: S.String, - isPublic: S.Boolean, - isTempProject: S.Boolean, - members: ProjectResponseMembersList, name: S.String, - prDeploys: S.Boolean, primaryEnvironmentId: S.NullOr(S.String), - subscriptionPlanLimit: S.Unknown, - subscriptionType: SubscriptionPlanType, - team: S.NullOr(InviteCodeUseResponseTeam), - teamId: S.NullOr(S.String), updatedAt: S.String, - workspace: S.NullOr(ProjectResponseWorkspace), workspaceId: S.NullOr(S.String), services: ProjectResponseServices, buckets: ProjectResponseBuckets, From 48435b0dbfac966aab8a7c76abe2e36458a7f6df Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 26 Aug 2026 20:42:26 -0700 Subject: [PATCH 4/9] revert(railway): drop GraphQlHttpGate Do not serialize GraphQL behind a process lock. Slim project({id}) and environments selections are the nuke-scan fix; wrapping HttpClient broke .items() streams. --- packages/railway/src/index.ts | 2 -- packages/railway/src/protocol.ts | 51 +------------------------------- 2 files changed, 1 insertion(+), 52 deletions(-) diff --git a/packages/railway/src/index.ts b/packages/railway/src/index.ts index 2011957f1..90c7a14cf 100644 --- a/packages/railway/src/index.ts +++ b/packages/railway/src/index.ts @@ -26,8 +26,6 @@ export * from "./errors.ts"; export * as T from "./traits.ts"; export { RailwayGraphqlProtocol, - GraphQlHttpGate, - gateHttpClient, type RailwayOpError, type RailwayOpContext, } from "./protocol.ts"; diff --git a/packages/railway/src/protocol.ts b/packages/railway/src/protocol.ts index f47a31e64..3f9328766 100644 --- a/packages/railway/src/protocol.ts +++ b/packages/railway/src/protocol.ts @@ -28,9 +28,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; import type * as AST from "effect/SchemaAST"; -import * as HttpClient from "effect/unstable/http/HttpClient"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; import type * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -371,51 +370,3 @@ export const RailwayGraphqlProtocol: Layer.Layer = Layer.succeed( decode, }), ); - -/** - * Max in-flight GraphQL POSTs. Railway's public API 429s hard when a - * consumer (alchemy nuke scans ~20 resource `list()`s) fans out. Override - * with `DISTILLED_RAILWAY_GRAPHQL_CONCURRENCY`. - */ -const graphqlConcurrency = (): number => { - const raw = process.env.DISTILLED_RAILWAY_GRAPHQL_CONCURRENCY; - if (raw === undefined || raw === "") return 2; - const n = Number(raw); - if (!Number.isFinite(n) || n < 1) return 2; - return Math.trunc(n); -}; - -const graphqlSemaphore = Semaphore.makeUnsafe(graphqlConcurrency()); - -/** - * Wrap an {@link HttpClient.HttpClient} so GraphQL calls take a global - * permit. Provide over `FetchHttpClient.layer` so every Railway operation - * in the process shares the cap. - */ -export const gateHttpClient = ( - client: HttpClient.HttpClient, -): HttpClient.HttpClient => - HttpClient.transform(client, (effect) => - graphqlSemaphore.withPermits(1)(effect), - ); - -/** - * Replaces `HttpClient.HttpClient` with a gated wrapper of the client - * already in context. - * - * @example - * ```ts - * GraphQlHttpGate.pipe(Layer.provide(FetchHttpClient.layer)) - * ``` - */ -export const GraphQlHttpGate: Layer.Layer< - HttpClient.HttpClient, - never, - HttpClient.HttpClient -> = Layer.effect( - HttpClient.HttpClient, - Effect.gen(function* () { - const inner = yield* HttpClient.HttpClient; - return gateHttpClient(inner); - }), -); From 9f24dc6992561aa71719e6226e120e564198f1bd Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 26 Aug 2026 21:17:26 -0700 Subject: [PATCH 5/9] fix(railway): select volumeInstances on environments list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Volume.list can enumerate from the slim environments connection. Nuke must not call environment({id}) — that GET still selects canvasGroupRefs/meta and is what made the scan spin. --- packages/railway/.generated-specs/railway.json | 8 +++++++- .../railway/environments.volumeInstances.json | 18 ++++++++++++++++++ packages/railway/src/services/railway.ts | 4 +++- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 packages/railway/patches/railway/environments.volumeInstances.json diff --git a/packages/railway/.generated-specs/railway.json b/packages/railway/.generated-specs/railway.json index 877c94f47..24484c369 100644 --- a/packages/railway/.generated-specs/railway.json +++ b/packages/railway/.generated-specs/railway.json @@ -8619,7 +8619,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", + "query": "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n volumeInstances(first: 50) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", "operationName": "environments", "type": "query" } @@ -8731,6 +8731,12 @@ "traits": { "smithy.api#required": {} } + }, + "volumeInstances": { + "target": "com.railway.api#EnvironmentResponseVolumeInstances", + "traits": { + "smithy.api#required": {} + } } } }, diff --git a/packages/railway/patches/railway/environments.volumeInstances.json b/packages/railway/patches/railway/environments.volumeInstances.json new file mode 100644 index 000000000..2a934e358 --- /dev/null +++ b/packages/railway/patches/railway/environments.volumeInstances.json @@ -0,0 +1,18 @@ +{ + "description": "Select volumeInstances on the environments list connection so Volume.list can enumerate from the slim list query. Do not environment({id}) during nuke — that GET still selects canvasGroupRefs/meta.", + "patches": [ + { + "op": "add", + "path": "/shapes/com.railway.api#EnvironmentsResponseEdgesItemNode/members/volumeInstances", + "value": { + "target": "com.railway.api#EnvironmentResponseVolumeInstances", + "traits": { "smithy.api#required": {} } + } + }, + { + "op": "replace", + "path": "/shapes/com.railway.api#EnvironmentsRequest/traits/com.railway.graphql#operation/query", + "value": "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n volumeInstances(first: 50) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}" + } + ] +} diff --git a/packages/railway/src/services/railway.ts b/packages/railway/src/services/railway.ts index 310757fea..6ac561e5e 100644 --- a/packages/railway/src/services/railway.ts +++ b/packages/railway/src/services/railway.ts @@ -6025,7 +6025,7 @@ export const EnvironmentsRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", + "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n volumeInstances(first: 50) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", operationName: "environments", type: "query", }), @@ -6042,6 +6042,7 @@ export interface EnvironmentsResponseEdgesItemNode { name: string; projectId: string; updatedAt: string; + volumeInstances: EnvironmentResponseVolumeInstances; } export const EnvironmentsResponseEdgesItemNode = /*@__PURE__*/ S.suspend(() => S.Struct({ @@ -6052,6 +6053,7 @@ export const EnvironmentsResponseEdgesItemNode = /*@__PURE__*/ S.suspend(() => name: S.String, projectId: S.String, updatedAt: S.String, + volumeInstances: EnvironmentResponseVolumeInstances, }), ).annotate({ identifier: "EnvironmentsResponseEdgesItemNode", From 975caddd5c3d69978946c9aa8ab1b09ae9331652 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 26 Aug 2026 23:37:23 -0700 Subject: [PATCH 6/9] fix(railway): slim volume queries and type GraphQL not-found errors Select volumeInstances on volumeCreate and environments list, drop the nested environment from volumeInstance GET, stamp service.templateId on the slim project query, and map ServiceInstance/BucketInstance-not-found plus "creating too quickly" onto RailwayNotFound/RailwayRateLimited. GraphQL rate limits without Retry-After default to 31s. --- .../railway/.generated-specs/railway.json | 31 ++++---- .../railway/environmentVolumeInstances.json | 2 +- .../environmentVolumeInstancesConfig.json | 2 +- .../railway/environments.volumeInstances.json | 2 +- .../railway/projectServicesTemplateId.json | 21 +++++ .../railway/patches/railway/volumeCreate.json | 22 ++++++ .../patches/railway/volumeInstance.slim.json | 14 ++++ packages/railway/src/errors.ts | 20 +++++ packages/railway/src/protocol.ts | 19 ++++- packages/railway/src/services/railway.ts | 77 +++---------------- 10 files changed, 122 insertions(+), 88 deletions(-) create mode 100644 packages/railway/patches/railway/projectServicesTemplateId.json create mode 100644 packages/railway/patches/railway/volumeCreate.json create mode 100644 packages/railway/patches/railway/volumeInstance.slim.json diff --git a/packages/railway/.generated-specs/railway.json b/packages/railway/.generated-specs/railway.json index 24484c369..ac5927fd4 100644 --- a/packages/railway/.generated-specs/railway.json +++ b/packages/railway/.generated-specs/railway.json @@ -7197,7 +7197,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "query environment($id: String!, $projectId: String) {\n environment(id: $id, projectId: $projectId) {\n canAccess\n canvasGroupRefs\n config\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n meta {\n baseBranch\n branch\n latestSuccessfulGitHubDeploymentId\n prCommentId\n prNumber\n prRepo\n prTitle\n skippedResourceIds\n }\n name\n projectId\n unmergedChangesCount\n updatedAt\n volumeInstances(first: 50) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n}", + "query": "query environment($id: String!, $projectId: String) {\n environment(id: $id, projectId: $projectId) {\n canAccess\n canvasGroupRefs\n config\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n meta {\n baseBranch\n branch\n latestSuccessfulGitHubDeploymentId\n prCommentId\n prNumber\n prRepo\n prTitle\n skippedResourceIds\n }\n name\n projectId\n unmergedChangesCount\n updatedAt\n volumeInstances(first: 200) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n}", "operationName": "environment", "type": "query" } @@ -8619,7 +8619,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n volumeInstances(first: 50) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", + "query": "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n volumeInstances(first: 200) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", "operationName": "environments", "type": "query" } @@ -15752,7 +15752,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "query project($id: String!) {\n project(id: $id) {\n baseEnvironmentId\n createdAt\n deletedAt\n description\n id\n name\n primaryEnvironmentId\n updatedAt\n workspaceId\n services(first: 50) {\n edges {\n node {\n createdAt\n deletedAt\n groupId\n id\n name\n projectId\n }\n }\n }\n buckets(first: 50) {\n edges {\n node {\n createdAt\n groupId\n id\n name\n projectId\n updatedAt\n }\n }\n }\n groups(first: 50) {\n edges {\n node {\n color\n groupId\n icon\n id\n isCollapsed\n name\n projectId\n }\n }\n }\n }\n}", + "query": "query project($id: String!) {\n project(id: $id) {\n baseEnvironmentId\n createdAt\n deletedAt\n description\n id\n name\n primaryEnvironmentId\n updatedAt\n workspaceId\n services(first: 50) {\n edges {\n node {\n createdAt\n deletedAt\n groupId\n id\n name\n projectId\n templateId\n }\n }\n }\n buckets(first: 50) {\n edges {\n node {\n createdAt\n groupId\n id\n name\n projectId\n updatedAt\n }\n }\n }\n groups(first: 50) {\n edges {\n node {\n color\n groupId\n icon\n id\n isCollapsed\n name\n projectId\n }\n }\n }\n }\n}", "operationName": "project", "type": "query" } @@ -26392,7 +26392,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "query volumeInstance($id: String!) {\n volumeInstance(id: $id) {\n createdAt\n currentSizeMB\n deletedAt\n environment {\n canAccess\n canvasGroupRefs\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n unmergedChangesCount\n updatedAt\n }\n environmentId\n externalId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n}", + "query": "query volumeInstance($id: String!) {\n volumeInstance(id: $id) {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n externalId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n}", "operationName": "volumeInstance", "type": "query" } @@ -26623,12 +26623,6 @@ "com.railway.graphql#nullable": {} } }, - "environment": { - "target": "com.railway.api#VolumeInstanceResponseEnvironment", - "traits": { - "smithy.api#required": {} - } - }, "environmentId": { "target": "smithy.api#String", "traits": { @@ -53171,7 +53165,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "mutation volumeCreate($input: VolumeCreateInput!) {\n volumeCreate(input: $input) {\n createdAt\n id\n name\n project {\n baseEnvironmentId\n botPrEnvironments\n createdAt\n deletedAt\n description\n expiredAt\n featureFlags\n focusedPrEnvironments\n id\n isPublic\n isTempProject\n name\n prDeploys\n primaryEnvironmentId\n subscriptionPlanLimit\n subscriptionType\n teamId\n updatedAt\n workspaceId\n }\n projectId\n }\n}", + "query": "mutation volumeCreate($input: VolumeCreateInput!) {\n volumeCreate(input: $input) {\n createdAt\n id\n name\n projectId\n volumeInstances(first: 10) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n}", "operationName": "volumeCreate", "type": "mutation" } @@ -53330,14 +53324,14 @@ "smithy.api#required": {} } }, - "project": { - "target": "com.railway.api#VolumeCreateResponseProject", + "projectId": { + "target": "smithy.api#String", "traits": { "smithy.api#required": {} } }, - "projectId": { - "target": "smithy.api#String", + "volumeInstances": { + "target": "com.railway.api#EnvironmentResponseVolumeInstances", "traits": { "smithy.api#required": {} } @@ -57340,6 +57334,13 @@ "smithy.api#required": {}, "com.railway.graphql#nullable": {} } + }, + "templateId": { + "target": "smithy.api#String", + "traits": { + "smithy.api#required": {}, + "com.railway.graphql#nullable": {} + } } } }, diff --git a/packages/railway/patches/railway/environmentVolumeInstances.json b/packages/railway/patches/railway/environmentVolumeInstances.json index 26165c21b..cdb8eb364 100644 --- a/packages/railway/patches/railway/environmentVolumeInstances.json +++ b/packages/railway/patches/railway/environmentVolumeInstances.json @@ -121,7 +121,7 @@ { "op": "replace", "path": "/shapes/com.railway.api#EnvironmentRequest/traits/com.railway.graphql#operation/query", - "value": "query environment($id: String!, $projectId: String) {\n environment(id: $id, projectId: $projectId) {\n canAccess\n canvasGroupRefs\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n meta {\n baseBranch\n branch\n latestSuccessfulGitHubDeploymentId\n prCommentId\n prNumber\n prRepo\n prTitle\n skippedResourceIds\n }\n name\n projectId\n unmergedChangesCount\n updatedAt\n volumeInstances(first: 50) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n}" + "value": "query environment($id: String!, $projectId: String) {\n environment(id: $id, projectId: $projectId) {\n canAccess\n canvasGroupRefs\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n meta {\n baseBranch\n branch\n latestSuccessfulGitHubDeploymentId\n prCommentId\n prNumber\n prRepo\n prTitle\n skippedResourceIds\n }\n name\n projectId\n unmergedChangesCount\n updatedAt\n volumeInstances(first: 200) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n}" } ] } diff --git a/packages/railway/patches/railway/environmentVolumeInstancesConfig.json b/packages/railway/patches/railway/environmentVolumeInstancesConfig.json index d3c318af9..f588f3335 100644 --- a/packages/railway/patches/railway/environmentVolumeInstancesConfig.json +++ b/packages/railway/patches/railway/environmentVolumeInstancesConfig.json @@ -12,7 +12,7 @@ { "op": "replace", "path": "/shapes/com.railway.api#EnvironmentRequest/traits/com.railway.graphql#operation/query", - "value": "query environment($id: String!, $projectId: String) {\n environment(id: $id, projectId: $projectId) {\n canAccess\n canvasGroupRefs\n config\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n meta {\n baseBranch\n branch\n latestSuccessfulGitHubDeploymentId\n prCommentId\n prNumber\n prRepo\n prTitle\n skippedResourceIds\n }\n name\n projectId\n unmergedChangesCount\n updatedAt\n volumeInstances(first: 50) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n}" + "value": "query environment($id: String!, $projectId: String) {\n environment(id: $id, projectId: $projectId) {\n canAccess\n canvasGroupRefs\n config\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n meta {\n baseBranch\n branch\n latestSuccessfulGitHubDeploymentId\n prCommentId\n prNumber\n prRepo\n prTitle\n skippedResourceIds\n }\n name\n projectId\n unmergedChangesCount\n updatedAt\n volumeInstances(first: 200) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n}" } ] } diff --git a/packages/railway/patches/railway/environments.volumeInstances.json b/packages/railway/patches/railway/environments.volumeInstances.json index 2a934e358..4340c4536 100644 --- a/packages/railway/patches/railway/environments.volumeInstances.json +++ b/packages/railway/patches/railway/environments.volumeInstances.json @@ -12,7 +12,7 @@ { "op": "replace", "path": "/shapes/com.railway.api#EnvironmentsRequest/traits/com.railway.graphql#operation/query", - "value": "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n volumeInstances(first: 50) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}" + "value": "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n volumeInstances(first: 200) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}" } ] } diff --git a/packages/railway/patches/railway/projectServicesTemplateId.json b/packages/railway/patches/railway/projectServicesTemplateId.json new file mode 100644 index 000000000..26ece5ed6 --- /dev/null +++ b/packages/railway/patches/railway/projectServicesTemplateId.json @@ -0,0 +1,21 @@ +{ + "description": "Select service.templateId on the slim project GET so Template.list can observe marketplace deploys without templateSourceForProject (often Forbidden on a shared project).", + "patches": [ + { + "op": "add", + "path": "/shapes/com.railway.api#ProjectResponseServicesEdgesItemNode/members/templateId", + "value": { + "target": "smithy.api#String", + "traits": { + "smithy.api#required": {}, + "com.railway.graphql#nullable": {} + } + } + }, + { + "op": "replace", + "path": "/shapes/com.railway.api#ProjectRequest/traits/com.railway.graphql#operation/query", + "value": "query project($id: String!) {\n project(id: $id) {\n baseEnvironmentId\n createdAt\n deletedAt\n description\n id\n name\n primaryEnvironmentId\n updatedAt\n workspaceId\n services(first: 50) {\n edges {\n node {\n createdAt\n deletedAt\n groupId\n id\n name\n projectId\n templateId\n }\n }\n }\n buckets(first: 50) {\n edges {\n node {\n createdAt\n groupId\n id\n name\n projectId\n updatedAt\n }\n }\n }\n groups(first: 50) {\n edges {\n node {\n color\n groupId\n icon\n id\n isCollapsed\n name\n projectId\n }\n }\n }\n }\n}" + } + ] +} diff --git a/packages/railway/patches/railway/volumeCreate.json b/packages/railway/patches/railway/volumeCreate.json new file mode 100644 index 000000000..e7f5cf236 --- /dev/null +++ b/packages/railway/patches/railway/volumeCreate.json @@ -0,0 +1,22 @@ +{ + "description": "Slim volumeCreate: drop nested Project (subscriptionType/canvasGroupRefs class of decode) and select volumeInstances so reconcile can wait on the instance id instead of listing the environment.", + "patches": [ + { + "op": "replace", + "path": "/shapes/com.railway.api#VolumeCreateRequest/traits/com.railway.graphql#operation/query", + "value": "mutation volumeCreate($input: VolumeCreateInput!) {\n volumeCreate(input: $input) {\n createdAt\n id\n name\n projectId\n volumeInstances(first: 10) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n}" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#VolumeCreateResponse/members/project" + }, + { + "op": "add", + "path": "/shapes/com.railway.api#VolumeCreateResponse/members/volumeInstances", + "value": { + "target": "com.railway.api#EnvironmentResponseVolumeInstances", + "traits": { "smithy.api#required": {} } + } + } + ] +} diff --git a/packages/railway/patches/railway/volumeInstance.slim.json b/packages/railway/patches/railway/volumeInstance.slim.json new file mode 100644 index 000000000..0635908c9 --- /dev/null +++ b/packages/railway/patches/railway/volumeInstance.slim.json @@ -0,0 +1,14 @@ +{ + "description": "Drop nested environment (canvasGroupRefs/meta) from volumeInstance GET. Alchemy only needs instance scalars plus volume { id, name, projectId }.", + "patches": [ + { + "op": "replace", + "path": "/shapes/com.railway.api#VolumeInstanceRequest/traits/com.railway.graphql#operation/query", + "value": "query volumeInstance($id: String!) {\n volumeInstance(id: $id) {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n externalId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n}" + }, + { + "op": "remove", + "path": "/shapes/com.railway.api#VolumeInstanceResponse/members/environment" + } + ] +} diff --git a/packages/railway/src/errors.ts b/packages/railway/src/errors.ts index 6e46da5c2..e93860458 100644 --- a/packages/railway/src/errors.ts +++ b/packages/railway/src/errors.ts @@ -194,6 +194,11 @@ export const RAILWAY_ERROR_MATCHERS: ReadonlyArray<{ messageIncludes: "creating projects too quickly", error: RailwayRateLimited, }, + { + code: "INTERNAL_SERVER_ERROR", + messageIncludes: "creating volumes too quickly", + error: RailwayRateLimited, + }, { code: "INTERNAL_SERVER_ERROR", messageIncludes: "Invalid project name", @@ -209,6 +214,21 @@ export const RAILWAY_ERROR_MATCHERS: ReadonlyArray<{ messageIncludes: "Project not found", error: RailwayNotFound, }, + { + code: "INTERNAL_SERVER_ERROR", + messageIncludes: "ServiceInstance not found", + error: RailwayNotFound, + }, + { + code: "INTERNAL_SERVER_ERROR", + messageIncludes: "BucketInstance not found", + error: RailwayNotFound, + }, + { + code: "INTERNAL_SERVER_ERROR", + messageIncludes: "VolumeInstance not found", + error: RailwayNotFound, + }, { code: "INTERNAL_SERVER_ERROR", messageIncludes: "Source canvas view not found", diff --git a/packages/railway/src/protocol.ts b/packages/railway/src/protocol.ts index 3f9328766..10b36063f 100644 --- a/packages/railway/src/protocol.ts +++ b/packages/railway/src/protocol.ts @@ -24,6 +24,7 @@ * On success the value of `data.` (see * `T.ResponsePath`) is returned verbatim. */ +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; @@ -108,6 +109,20 @@ const RestErrorResponse = Schema.Struct({ const decodeEnvelope = Schema.decodeUnknownOption(GraphQLEnvelope); const decodeRest = Schema.decodeUnknownOption(RestErrorResponse); +/** GraphQL rate limits often arrive as HTTP 200 with no Retry-After header. */ +const retryAfterForRateLimit = ( + message: string, + headers?: Record, +) => { + const fromHeader = parseRetryAfterForStatus(429, headers); + if (fromHeader !== undefined) return fromHeader; + const match = /per (\d+) seconds/.exec(message); + if (match !== null) { + return Duration.seconds(Number(match[1]) + 1); + } + return Duration.seconds(31); +}; + type StatusClass = new (args: { message: string; retryAfter?: ReturnType; @@ -143,7 +158,7 @@ const matchError = ( }); if (matcher) { if (matcher.error === RailwayRateLimited) { - const retryAfter = parseRetryAfterForStatus(429, headers); + const retryAfter = retryAfterForRateLimit(message, headers); return fail(new RailwayRateLimited({ message, retryAfter })); } return fail(new matcher.error({ message })); @@ -154,7 +169,7 @@ const matchError = ( if (TypedClass) { // Rate-limit errors carry a retry hint when the gateway sent one. if (TypedClass === RailwayRateLimited) { - const retryAfter = parseRetryAfterForStatus(429, headers); + const retryAfter = retryAfterForRateLimit(message, headers); return fail(new RailwayRateLimited({ message, retryAfter })); } return fail(new TypedClass({ message })); diff --git a/packages/railway/src/services/railway.ts b/packages/railway/src/services/railway.ts index 6ac561e5e..4134d0efd 100644 --- a/packages/railway/src/services/railway.ts +++ b/packages/railway/src/services/railway.ts @@ -5151,7 +5151,7 @@ export const EnvironmentRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "query environment($id: String!, $projectId: String) {\n environment(id: $id, projectId: $projectId) {\n canAccess\n canvasGroupRefs\n config\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n meta {\n baseBranch\n branch\n latestSuccessfulGitHubDeploymentId\n prCommentId\n prNumber\n prRepo\n prTitle\n skippedResourceIds\n }\n name\n projectId\n unmergedChangesCount\n updatedAt\n volumeInstances(first: 50) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n}", + "query environment($id: String!, $projectId: String) {\n environment(id: $id, projectId: $projectId) {\n canAccess\n canvasGroupRefs\n config\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n meta {\n baseBranch\n branch\n latestSuccessfulGitHubDeploymentId\n prCommentId\n prNumber\n prRepo\n prTitle\n skippedResourceIds\n }\n name\n projectId\n unmergedChangesCount\n updatedAt\n volumeInstances(first: 200) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n}", operationName: "environment", type: "query", }), @@ -6025,7 +6025,7 @@ export const EnvironmentsRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n volumeInstances(first: 50) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", + "query environments($after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int, $projectId: String!) {\n environments(after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last, projectId: $projectId) {\n edges {\n cursor\n node {\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n updatedAt\n volumeInstances(first: 200) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", operationName: "environments", type: "query", }), @@ -10930,7 +10930,7 @@ export const ProjectRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "query project($id: String!) {\n project(id: $id) {\n baseEnvironmentId\n createdAt\n deletedAt\n description\n id\n name\n primaryEnvironmentId\n updatedAt\n workspaceId\n services(first: 50) {\n edges {\n node {\n createdAt\n deletedAt\n groupId\n id\n name\n projectId\n }\n }\n }\n buckets(first: 50) {\n edges {\n node {\n createdAt\n groupId\n id\n name\n projectId\n updatedAt\n }\n }\n }\n groups(first: 50) {\n edges {\n node {\n color\n groupId\n icon\n id\n isCollapsed\n name\n projectId\n }\n }\n }\n }\n}", + "query project($id: String!) {\n project(id: $id) {\n baseEnvironmentId\n createdAt\n deletedAt\n description\n id\n name\n primaryEnvironmentId\n updatedAt\n workspaceId\n services(first: 50) {\n edges {\n node {\n createdAt\n deletedAt\n groupId\n id\n name\n projectId\n templateId\n }\n }\n }\n buckets(first: 50) {\n edges {\n node {\n createdAt\n groupId\n id\n name\n projectId\n updatedAt\n }\n }\n }\n groups(first: 50) {\n edges {\n node {\n color\n groupId\n icon\n id\n isCollapsed\n name\n projectId\n }\n }\n }\n }\n}", operationName: "project", type: "query", }), @@ -10944,6 +10944,7 @@ export interface ProjectResponseServicesEdgesItemNode { name: string; projectId: string; groupId: string | null; + templateId: string | null; } export const ProjectResponseServicesEdgesItemNode = /*@__PURE__*/ S.suspend( () => @@ -10954,6 +10955,7 @@ export const ProjectResponseServicesEdgesItemNode = /*@__PURE__*/ S.suspend( name: S.String, projectId: S.String, groupId: S.NullOr(S.String), + templateId: S.NullOr(S.String), }), ).annotate({ identifier: "ProjectResponseServicesEdgesItemNode", @@ -21697,7 +21699,7 @@ export const VolumeCreateRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "mutation volumeCreate($input: VolumeCreateInput!) {\n volumeCreate(input: $input) {\n createdAt\n id\n name\n project {\n baseEnvironmentId\n botPrEnvironments\n createdAt\n deletedAt\n description\n expiredAt\n featureFlags\n focusedPrEnvironments\n id\n isPublic\n isTempProject\n name\n prDeploys\n primaryEnvironmentId\n subscriptionPlanLimit\n subscriptionType\n teamId\n updatedAt\n workspaceId\n }\n projectId\n }\n}", + "mutation volumeCreate($input: VolumeCreateInput!) {\n volumeCreate(input: $input) {\n createdAt\n id\n name\n projectId\n volumeInstances(first: 10) {\n edges {\n node {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n }\n }\n }\n}", operationName: "volumeCreate", type: "mutation", }), @@ -21706,75 +21708,21 @@ export const VolumeCreateRequest = /*@__PURE__*/ S.suspend(() => identifier: "VolumeCreateRequest", }) as any as S.Schema; -export type VolumeCreateResponseProjectFeatureFlagsList = - Array; -export const VolumeCreateResponseProjectFeatureFlagsList = - /*@__PURE__*/ S.Array( - ActiveProjectFeatureFlag, - ) as any as S.Schema; - -export interface VolumeCreateResponseProject { - baseEnvironmentId: string | null; - botPrEnvironments: boolean; - createdAt: string; - deletedAt: string | null; - description: string | null; - expiredAt: string | null; - featureFlags: VolumeCreateResponseProjectFeatureFlagsList; - focusedPrEnvironments: boolean; - id: string; - isPublic: boolean; - isTempProject: boolean; - name: string; - prDeploys: boolean; - primaryEnvironmentId: string | null; - subscriptionPlanLimit: unknown; - subscriptionType: SubscriptionPlanType; - teamId: string | null; - updatedAt: string; - workspaceId: string | null; -} -export const VolumeCreateResponseProject = /*@__PURE__*/ S.suspend(() => - S.Struct({ - baseEnvironmentId: S.NullOr(S.String), - botPrEnvironments: S.Boolean, - createdAt: S.String, - deletedAt: S.NullOr(S.String), - description: S.NullOr(S.String), - expiredAt: S.NullOr(S.String), - featureFlags: VolumeCreateResponseProjectFeatureFlagsList, - focusedPrEnvironments: S.Boolean, - id: S.String, - isPublic: S.Boolean, - isTempProject: S.Boolean, - name: S.String, - prDeploys: S.Boolean, - primaryEnvironmentId: S.NullOr(S.String), - subscriptionPlanLimit: S.Unknown, - subscriptionType: SubscriptionPlanType, - teamId: S.NullOr(S.String), - updatedAt: S.String, - workspaceId: S.NullOr(S.String), - }), -).annotate({ - identifier: "VolumeCreateResponseProject", -}) as any as S.Schema; - /** Selection set for `volumeCreate` (unwrapped from the GraphQL `data` envelope). */ export interface VolumeCreateResponse { createdAt: string; id: string; name: string; - project: VolumeCreateResponseProject; projectId: string; + volumeInstances: EnvironmentResponseVolumeInstances; } export const VolumeCreateResponse = /*@__PURE__*/ S.suspend(() => S.Struct({ createdAt: S.String, id: S.String, name: S.String, - project: VolumeCreateResponseProject, projectId: S.String, + volumeInstances: EnvironmentResponseVolumeInstances, }).pipe(T.ResponsePath("volumeCreate")), ).annotate({ identifier: "VolumeCreateResponse", @@ -21818,7 +21766,7 @@ export const VolumeInstanceRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "query volumeInstance($id: String!) {\n volumeInstance(id: $id) {\n createdAt\n currentSizeMB\n deletedAt\n environment {\n canAccess\n canvasGroupRefs\n configEtag\n createdAt\n deletedAt\n id\n isEphemeral\n name\n projectId\n unmergedChangesCount\n updatedAt\n }\n environmentId\n externalId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n}", + "query volumeInstance($id: String!) {\n volumeInstance(id: $id) {\n createdAt\n currentSizeMB\n deletedAt\n environmentId\n externalId\n id\n isPendingDeletion\n mountPath\n region\n serviceId\n sizeMB\n state\n volume {\n createdAt\n id\n name\n projectId\n }\n volumeId\n }\n}", operationName: "volumeInstance", type: "query", }), @@ -21827,11 +21775,6 @@ export const VolumeInstanceRequest = /*@__PURE__*/ S.suspend(() => identifier: "VolumeInstanceRequest", }) as any as S.Schema; -export type VolumeInstanceResponseEnvironment = - AdminVolumeInstancesForVolumeResultItemEnvironment; -export const VolumeInstanceResponseEnvironment = - AdminVolumeInstancesForVolumeResultItemEnvironment; - export type VolumeInstanceResponseServiceFeatureFlagsList = Array; export const VolumeInstanceResponseServiceFeatureFlagsList = @@ -21886,7 +21829,6 @@ export interface VolumeInstanceResponse { createdAt: string; currentSizeMB: number; deletedAt: string | null; - environment: AdminVolumeInstancesForVolumeResultItemEnvironment; environmentId: string; externalId: string | null; id: string; @@ -21905,7 +21847,6 @@ export const VolumeInstanceResponse = /*@__PURE__*/ S.suspend(() => createdAt: S.String, currentSizeMB: S.Number, deletedAt: S.NullOr(S.String), - environment: AdminVolumeInstancesForVolumeResultItemEnvironment, environmentId: S.String, externalId: S.NullOr(S.String), id: S.String, From e2ebe963911edc4ecaed7aeca2e58e8b52f8eb79 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Fri, 28 Aug 2026 10:14:52 -0700 Subject: [PATCH 7/9] fix(aws/s3): type deleteObject missing-key as NoSuchKey S3 (and Tigris) report a missing object as NoSuchKey. deleteObject was patched to NotFound, so Fly.Website.AssetDeployment could not catchTag the same error AWS S3 getObject uses. --- packages/aws/patches/s3.json | 4 ++-- packages/aws/src/services/s3.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/aws/patches/s3.json b/packages/aws/patches/s3.json index 4d74c9d0f..28c70070c 100644 --- a/packages/aws/patches/s3.json +++ b/packages/aws/patches/s3.json @@ -181,8 +181,8 @@ "RequestLimitExceeded", "SlowDown", "NoSuchBucket", - "PermanentRedirect", - "NotFound" + "NoSuchKey", + "PermanentRedirect" ] }, "putObject": { diff --git a/packages/aws/src/services/s3.ts b/packages/aws/src/services/s3.ts index 0fc126898..0085e424f 100644 --- a/packages/aws/src/services/s3.ts +++ b/packages/aws/src/services/s3.ts @@ -14820,8 +14820,8 @@ export type DeleteObjectError = | RequestLimitExceeded | SlowDown | NoSuchBucket + | NoSuchKey | PermanentRedirect - | NotFound | CommonErrors; /** * Removes an object from a bucket. The behavior depends on the bucket's versioning state: @@ -14925,8 +14925,8 @@ export const deleteObject: API.OperationMethod< RequestLimitExceeded, SlowDown, NoSuchBucket, + NoSuchKey, PermanentRedirect, - NotFound, ], protocol: AwsProtocol, retry: Retry, From 85972de470da018eea255a465d1aa216999ca43b Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Fri, 28 Aug 2026 10:14:58 -0700 Subject: [PATCH 8/9] fix(railway): type service-domain create retry as RailwayServiceDomainCreateFailed GraphQL INTERNAL_SERVER_ERROR "Failed to create service domain" is a transient instance-not-ready. Also map environment-create rate limits. --- packages/railway/src/errors.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/railway/src/errors.ts b/packages/railway/src/errors.ts index e93860458..dff637049 100644 --- a/packages/railway/src/errors.ts +++ b/packages/railway/src/errors.ts @@ -139,6 +139,18 @@ export class RailwayPlanLimitExceeded extends Schema.TaggedError()( + "RailwayServiceDomainCreateFailed", + { + message: Schema.String, + }, +).pipe(Category.withServerError, Category.withRetryable()) {} + /** The resolver failed (`INTERNAL_SERVER_ERROR`). */ export class RailwayInternalError extends Schema.TaggedError()( "RailwayInternalError", @@ -199,6 +211,21 @@ export const RAILWAY_ERROR_MATCHERS: ReadonlyArray<{ messageIncludes: "creating volumes too quickly", error: RailwayRateLimited, }, + { + code: "INTERNAL_SERVER_ERROR", + messageIncludes: "creating environments too quickly", + error: RailwayRateLimited, + }, + { + code: "INTERNAL_SERVER_ERROR", + messageIncludes: "one environment can be created per user every 30s", + error: RailwayRateLimited, + }, + { + code: "INTERNAL_SERVER_ERROR", + messageIncludes: "Failed to create service domain", + error: RailwayServiceDomainCreateFailed, + }, { code: "INTERNAL_SERVER_ERROR", messageIncludes: "Invalid project name", @@ -248,6 +275,7 @@ export type RailwayTypedErrors = | RailwayNotFound | RailwayValidationError | RailwayRateLimited + | RailwayServiceDomainCreateFailed | RailwayPlanLimitExceeded | RailwayInternalError; From 88b462a174a659d2aba729166e47e5d6bab306d5 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Fri, 28 Aug 2026 12:29:58 -0700 Subject: [PATCH 9/9] feat(hetzner,railway): type createServer quota errors and Boolean deployment mutations Hetzner createServer now surfaces ServerLimitExceeded and ServerPlacementError instead of Forbidden/PreconditionFailed message checks. convert.ts applies /shapes/ patches after OpenAPI conversion. Railway deploymentCancel/Remove/Stop/Restart queries match Boolean! (no subfields). GraphQL requests send source=alchemy so the public edge does not apply the anonymous 10 RPS cap, and retry-after also parses "every N s". --- .../hetzner/.generated-specs/servers.json | 56 ++++++++++++++++++- .../patches/servers/createServerErrors.json | 55 ++++++++++++++++++ packages/hetzner/scripts/convert.ts | 43 +++++++++++++- packages/hetzner/src/services/servers.ts | 30 +++++++++- .../railway/.generated-specs/railway.json | 8 +-- .../patches/railway/deploymentCancel.json | 10 ++++ .../patches/railway/deploymentRemove.json | 10 ++++ .../patches/railway/deploymentRestart.json | 10 ++++ .../patches/railway/deploymentStop.json | 10 ++++ packages/railway/src/protocol.ts | 16 +++++- packages/railway/src/services/railway.ts | 8 +-- 11 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 packages/hetzner/patches/servers/createServerErrors.json create mode 100644 packages/railway/patches/railway/deploymentCancel.json create mode 100644 packages/railway/patches/railway/deploymentRemove.json create mode 100644 packages/railway/patches/railway/deploymentRestart.json create mode 100644 packages/railway/patches/railway/deploymentStop.json diff --git a/packages/hetzner/.generated-specs/servers.json b/packages/hetzner/.generated-specs/servers.json index 06933f76f..a71a04264 100644 --- a/packages/hetzner/.generated-specs/servers.json +++ b/packages/hetzner/.generated-specs/servers.json @@ -3826,7 +3826,15 @@ "code": 200 }, "smithy.api#documentation": "Create a Server\n\nCreates a new Server. Returns preliminary information about the Server as well as an Action that covers progress of creation.\n\n#### Operation specific errors" - } + }, + "errors": [ + { + "target": "com.hetzner.servers#ServerLimitExceeded" + }, + { + "target": "com.hetzner.servers#ServerPlacementError" + } + ] }, "com.hetzner.servers#GetServerRequest": { "type": "structure", @@ -7300,6 +7308,52 @@ "smithy.api#title": "Hetzner Cloud API", "smithy.api#documentation": "# Overview\n\nThis is the official documentation for the Hetzner Cloud API.\n\n## Introduction\n\nThe Hetzner Cloud API operates over HTTPS and uses JSON as its data format. The API is a RESTful API and utilizes HTTP methods and HTTP status codes to specify requests and responses.\n\nAs an alternative to working directly with our API you may also consider to use:\n\n- Our CLI program [hcloud](https://github.com/hetznercloud/cli)\n- Our [library for Go](https://github.com/hetznercloud/hcloud-go)\n- Our [library for Python](https://github.com/hetznercloud/hcloud-python)\n\nYou can find even more libraries, tools and integrations on our [Awesome List on GitHub](https://github.com/hetznercloud/awesome-hcloud).\n\n### Open source credits\n\nIf you are developing an open-source project that supports or intends to add support for Hetzner APIs, you may be eligible for a free one-time credit of up to € 50 / $ 50 on your account. Please contact us via the support page on your [Hetzner Console](https://console.hetzner.cloud/support) and let us know the following:\n\n- The name of the project you are working on\n- A short description of the project\n- Link to the project website or repo where the project is hosted\n- Affiliation with / role in the project (e.g. project maintainer)\n- Link to some other open-source work you have already done (if you have done so)\n\n**Note:** We only consider rewards for projects that provide Hetzner-specific functionality or integrations. For example, our Object Storage exposes a standard S3 API without any Hetzner-specific extensions. Projects that focus solely on generic S3 capabilities (e.g., general S3 clients or SDKs) are not Hetzner-specific and are therefore not eligible for Hetzner Rewards.\n\n## Getting Started\n\nTo get started using the API you first need an API token. Sign in into the [Hetzner Console](https://console.hetzner.com/) choose a Project, go to `Security` → `API Tokens`, and generate a new token. Make sure to copy the token because it won’t be shown to you again. A token is bound to a Project, to interact with the API of another Project you have to create a new token inside the Project. Let’s say your new token is `LRK9DAWQ1ZAEFSrCNEEzLCUwhYX1U3g7wMg4dTlkkDC96fyDuyJ39nVbVjCKSDfj`.\n\nYou’re now ready to do your first request against the API. To get a list of all Servers in your Project, issue the example request on the right side using [curl](https://curl.se/).\n\nMake sure to replace the token in the example command with the token you have just created. Since your Project probably does not contain any Servers yet, the example response will look like the response on the right side. We will almost always provide a resource root like `servers` inside the example response. A response can also contain a `meta` object with information like [Pagination](#description/pagination).\n\n**Example Request**\n\n```shell\ncurl -H \"Authorization: Bearer LRK9DAWQ1ZAEFSrCNEEzLCUwhYX1U3g7wMg4dTlkkDC96fyDuyJ39nVbVjCKSDfj\" \\\n https://api.hetzner.cloud/v1/servers\n```\n\n**Example Response**\n\n```json\n{\n \"servers\": [],\n \"meta\": {\n \"pagination\": {\n \"page\": 1,\n \"per_page\": 25,\n \"previous_page\": null,\n \"next_page\": null,\n \"last_page\": 1,\n \"total_entries\": 0\n }\n }\n}\n```\n\n## Authentication\n\nAll requests to the Hetzner Cloud API must be authenticated via a API token. Include your secret API token in every request you send to the API with the `Authorization` HTTP header.\n\nTo create a new API token for your Project, switch into the [Hetzner Console](https://console.hetzner.com/) choose a Project, go to `Security` → `API Tokens`, and generate a new token.\n\n**Example Authorization header**\n\n```http\nAuthorization: Bearer LRK9DAWQ1ZAEFSrCNEEzLCUwhYX1U3g7wMg4dTlkkDC96fyDuyJ39nVbVjCKSDfj\n```\n\n## Query Parameters\n\nThe API makes use of query parameters to sort and filter responses. The parameter names and values must be URI encoded according to [RFC2396](https://datatracker.ietf.org/doc/html/rfc2396). Query parameters of type `array` can be used multiple times:\n\n**Example query parameters for pagination**\n\n```\nhttps://api.hetzner.cloud/v1/certificates?page=1&page_size=12\n```\n\n**Example use of multiple values for a parameter**\n\n```\nhttps://api.hetzner.cloud/v1/certificates?type=uploaded&type=managed\n```\n\n**Example use of an encoded parameter**\n\n```\nhttps://api.hetzner.cloud/v1/certificates?label_selector=key%3Dvalue\n```\n\n## Errors\n\nErrors are indicated by HTTP status codes. Further, the response of the request which generated the error contains an error code, an error message, and, optionally, error details. The schema of the error details object depends on the error code.\n\nThe error response contains the following keys:\n\n\n**Example response**\n\n```json\n{\n \"error\": {\n \"code\": \"invalid_input\",\n \"message\": \"invalid input in field 'broken_field': is too long\",\n \"details\": {\n \"fields\": [\n {\n \"name\": \"broken_field\",\n \"messages\": [\"is too long\"]\n }\n ]\n }\n }\n}\n```\n\n### Error Codes\n\n\n**invalid_input**\n\n```json\n{\n \"error\": {\n \"code\": \"invalid_input\",\n \"message\": \"invalid input in field 'broken_field': is too long\",\n \"details\": {\n \"fields\": [\n {\n \"name\": \"broken_field\",\n \"messages\": [\"is too long\"]\n }\n ]\n }\n }\n}\n```\n\n**uniqueness_error**\n\n```json\n{\n \"error\": {\n \"code\": \"uniqueness_error\",\n \"message\": \"SSH key with the same fingerprint already exists\",\n \"details\": {\n \"fields\": [\n {\n \"name\": \"public_key\"\n }\n ]\n }\n }\n}\n```\n\n**resource_limit_exceeded**\n\n```json\n{\n \"error\": {\n \"code\": \"resource_limit_exceeded\",\n \"message\": \"project limit exceeded\",\n \"details\": {\n \"limits\": [\n {\n \"name\": \"project_limit\"\n }\n ]\n }\n }\n}\n```\n\n**deprecated_api_endpoint**\n\n```json\n{\n \"error\": {\n \"code\": \"deprecated_api_endpoint\",\n \"message\": \"API functionality was removed\",\n \"details\": {\n \"announcement\": \"https://docs.hetzner.cloud/changelog#2023-07-20-foo-endpoint-is-deprecated\"\n }\n }\n}\n```\n\n## Actions\n\nActions represent asynchronous tasks within the API, targeting one or more resources. Triggering changes in the API may return a `running` action.\n\nAn action should be waited upon, until it reaches either the `success` or `error` state. Avoid polling the action's state too frequently to reduce the risk of exhausting your API requests and hitting the [rate limit](#description/rate-limiting).\n\nIf an action fails, it will contain details about the underlying error.\n\nOnce the asynchronous tasks have completed and the targeted resources are in a consistent state, the action is marked as succeeded.\n\nIn some cases, you may trigger multiple changes at once, and only wait for the returned actions at a later stage.\n\n## Labels\n\nLabels are `key/value` pairs that can be attached to all resources.\n\nValid label keys have two segments: an optional prefix and name, separated by a slash (`/`). The name segment is required and must be a string of 63 characters or less, beginning and ending with an alphanumeric character (`[a-z0-9A-Z]`) with dashes (`-`), underscores (`_`), dots (`.`), and alphanumerics between. The prefix is optional. If specified, the prefix must be a DNS subdomain: a series of DNS labels separated by dots (`.`), not longer than 253 characters in total, followed by a slash (`/`).\n\nValid label values must be a string of 63 characters or less and must be empty or begin and end with an alphanumeric character (`[a-z0-9A-Z]`) with dashes (`-`), underscores (`_`), dots (`.`), and alphanumerics between.\n\nThe `hetzner.cloud/` prefix is reserved and cannot be used.\n\n**Example Labels**\n\n```json\n{\n \"labels\": {\n \"environment\": \"development\",\n \"service\": \"backend\",\n \"example.com/my\": \"label\",\n \"just-a-key\": \"\"\n }\n}\n```\n\n## Label Selector\n\nFor resources with labels, you can filter resources by their labels using the label selector query language.\n\n\n### Examples\n\n- Returns all resources that have a `env=production` label and that don't have a `type=database` label:\n\n `env=production,type!=database`\n\n- Returns all resources that have a `env=testing` or `env=staging` label:\n\n `env in (testing,staging)`\n\n- Returns all resources that don't have a `type` label:\n\n `!type`\n\n## Pagination\n\nResponses which return multiple items support pagination. If they do support pagination, it can be controlled with following query string parameters:\n\n- A `page` parameter specifies the page to fetch. The number of the first page is 1.\n- A `per_page` parameter specifies the number of items returned per page. The default value is 25, the maximum value is 50 except otherwise specified in the documentation.\n\nResponses contain a `Link` header with pagination information.\n\nAdditionally, if the response body is JSON and the root object is an object, that object has a `pagination` object inside the `meta` object with pagination information:\n\n**Example Pagination**\n\n```json\n{\n \"servers\": [...],\n \"meta\": {\n \"pagination\": {\n \"page\": 2,\n \"per_page\": 25,\n \"previous_page\": 1,\n \"next_page\": 3,\n \"last_page\": 4,\n \"total_entries\": 100\n }\n }\n}\n```\n\nThe keys `previous_page`, `next_page`, `last_page`, and `total_entries` may be `null` when on the first page, last page, or when the total number of entries is unknown.\n\n**Example Pagination Link header**\n\n```http\nLink: ; rel=\"prev\",\n ; rel=\"next\",\n ; rel=\"last\"\n```\n\nLine breaks have been added for display purposes only and responses may only contain some of the above `rel` values.\n\n## Rate Limiting\n\nAll requests, whether they are authenticated or not, are subject to rate limiting. If you have reached your limit, your requests will be handled with a `429 Too Many Requests` error. Burst requests are allowed. Responses contain several headers which provide information about your current rate limit status.\n\n- The `RateLimit-Limit` header contains the total number of requests you can perform per hour.\n- The `RateLimit-Remaining` header contains the number of requests remaining in the current rate limit time frame.\n- The `RateLimit-Reset` header contains a UNIX timestamp of the point in time when your rate limit will have recovered, and you will have the full number of requests available again.\n\nThe default limit is 3600 requests per hour and per Project. The number of remaining requests increases gradually. For example, when your limit is 3600 requests per hour, the number of remaining requests will increase by 1 every second.\n\n## Server Metadata\n\nYour Server can discover metadata about itself by doing a HTTP request to specific URLs. The following data is available:\n\n\n**Example: Summary**\n\n```shell\n$ curl http://169.254.169.254/hetzner/v1/metadata\n```\n\n```yaml\navailability-zone: hel1-dc2\nhostname: my-server\ninstance-id: 42\npublic-ipv4: 1.2.3.4\nregion: eu-central\n```\n\n**Example: Hostname**\n\n```shell\n$ curl http://169.254.169.254/hetzner/v1/metadata/hostname\nmy-server\n```\n\n**Example: Instance ID**\n\n```shell\n$ curl http://169.254.169.254/hetzner/v1/metadata/instance-id\n42\n```\n\n**Example: Public IPv4**\n\n```shell\n$ curl http://169.254.169.254/hetzner/v1/metadata/public-ipv4\n1.2.3.4\n```\n\n**Example: Private Networks**\n\n```shell\n$ curl http://169.254.169.254/hetzner/v1/metadata/private-networks\n```\n\n```yaml\n- ip: 10.0.0.2\n alias_ips: [10.0.0.3, 10.0.0.4]\n interface_num: 1\n mac_address: 86:00:00:2a:7d:e0\n network_id: 1234\n network_name: nw-test1\n network: 10.0.0.0/8\n subnet: 10.0.0.0/24\n gateway: 10.0.0.1\n- ip: 192.168.0.2\n alias_ips: []\n interface_num: 2\n mac_address: 86:00:00:2a:7d:e1\n network_id: 4321\n network_name: nw-test2\n network: 192.168.0.0/16\n subnet: 192.168.0.0/24\n gateway: 192.168.0.1\n```\n\n**Example: Availability Zone**\n\n```shell\n$ curl http://169.254.169.254/hetzner/v1/metadata/availability-zone\nhel1-dc2\n```\n\n**Example: Region**\n\n```shell\n$ curl http://169.254.169.254/hetzner/v1/metadata/region\neu-central\n```\n\n## Sorting\n\nSome responses which return multiple items support sorting. If they do support sorting the documentation states which fields can be used for sorting. You specify sorting with the `sort` query string parameter. You can sort by multiple fields. You can set the sort direction by appending `:asc` or `:desc` to the field name. By default, ascending sorting is used.\n\n**Example: Sorting**\n\n```\nhttps://api.hetzner.cloud/v1/actions?sort=status\nhttps://api.hetzner.cloud/v1/actions?sort=status:asc\nhttps://api.hetzner.cloud/v1/actions?sort=status:desc\nhttps://api.hetzner.cloud/v1/actions?sort=status:asc&sort=command:desc\n```\n\n## Deprecation Notices\n\nYou can find all announced deprecations in our [Changelog](/changelog)." } + }, + "com.hetzner.servers#ServerLimitExceeded": { + "type": "structure", + "members": { + "code": { + "target": "smithy.api#Integer" + }, + "message": { + "target": "smithy.api#String" + } + }, + "traits": { + "smithy.api#error": "client", + "smithy.api#documentation": "The project has no remaining Server quota (Hetzner `resource_limit_exceeded`, HTTP 403). Often a race after a delete — retry.", + "com.distilled.openapi#errorMatchers": [ + { + "status": 403, + "message": { + "includes": "server limit" + } + } + ] + } + }, + "com.hetzner.servers#ServerPlacementError": { + "type": "structure", + "members": { + "code": { + "target": "smithy.api#Integer" + }, + "message": { + "target": "smithy.api#String" + } + }, + "traits": { + "smithy.api#error": "client", + "smithy.api#documentation": "The Server could not be placed (Hetzner `resource_unavailable` / `placement_error`, HTTP 412). Spread groups and location capacity recover — retry.", + "com.distilled.openapi#errorMatchers": [ + { + "status": 412, + "message": { + "includes": "placement" + } + } + ] + } } } } diff --git a/packages/hetzner/patches/servers/createServerErrors.json b/packages/hetzner/patches/servers/createServerErrors.json new file mode 100644 index 000000000..c2a9c82bd --- /dev/null +++ b/packages/hetzner/patches/servers/createServerErrors.json @@ -0,0 +1,55 @@ +{ + "description": "Type createServer's retryable quota and placement failures. The OpenAPI document only declares 4xx/5xx wildcards, so these currently land as Forbidden (403 resource_limit_exceeded, message contains 'server limit') and PreconditionFailed (412 resource_unavailable, message contains 'placement'). Dedicated tags let callers retry without matching on message text.", + "patches": [ + { + "op": "add", + "path": "/shapes/com.hetzner.servers#ServerLimitExceeded", + "value": { + "type": "structure", + "members": { + "code": { "target": "smithy.api#Integer" }, + "message": { "target": "smithy.api#String" } + }, + "traits": { + "smithy.api#error": "client", + "smithy.api#documentation": "The project has no remaining Server quota (Hetzner `resource_limit_exceeded`, HTTP 403). Often a race after a delete — retry.", + "com.distilled.openapi#errorMatchers": [ + { + "status": 403, + "message": { "includes": "server limit" } + } + ] + } + } + }, + { + "op": "add", + "path": "/shapes/com.hetzner.servers#ServerPlacementError", + "value": { + "type": "structure", + "members": { + "code": { "target": "smithy.api#Integer" }, + "message": { "target": "smithy.api#String" } + }, + "traits": { + "smithy.api#error": "client", + "smithy.api#documentation": "The Server could not be placed (Hetzner `resource_unavailable` / `placement_error`, HTTP 412). Spread groups and location capacity recover — retry.", + "com.distilled.openapi#errorMatchers": [ + { + "status": 412, + "message": { "includes": "placement" } + } + ] + } + } + }, + { + "op": "add", + "path": "/shapes/com.hetzner.servers#CreateServer/errors", + "value": [ + { "target": "com.hetzner.servers#ServerLimitExceeded" }, + { "target": "com.hetzner.servers#ServerPlacementError" } + ] + } + ] +} diff --git a/packages/hetzner/scripts/convert.ts b/packages/hetzner/scripts/convert.ts index 8e1fb3fdb..8d58ea87a 100644 --- a/packages/hetzner/scripts/convert.ts +++ b/packages/hetzner/scripts/convert.ts @@ -16,7 +16,9 @@ * last. Stale targets warn+skip — the spec is refetched from a live URL * and drifts — malformed patches fail the run). Patching per-slice * would hard-fail: a patch targeting one tag's paths doesn't resolve - * against another tag's slice. + * against another tag's slice. Ops whose path starts with `/shapes/` + * are Smithy patches (typed errors) and apply AFTER conversion, per + * tag — OpenAPI has no `/shapes/` tree. * 3. Bucket operations by PRIMARY (first) tag — every operation in this * spec carries exactly one, so there is no untagged-routing table the * way Vercel needs. @@ -82,9 +84,12 @@ if (!fs.existsSync(specPath)) { const fullSpec = JSON.parse(fs.readFileSync(specPath, "utf-8")); // ---- 2. Apply the patch chain ONCE to the full spec ------------------------ -// Cloudflare layout: patches//.json. Still applied to the -// bundled OpenAPI document here (generate.ts leaves patchesDir: false). +// Cloudflare layout: patches//.json. OpenAPI ops (`/paths/`, +// `/components/`, …) apply here; `/shapes/` ops apply to the converted +// Smithy model per tag (generate.ts leaves patchesDir: false). const SKIP_PATCH_NAMES = new Set(["_metadata.json"]); +const isSmithyPatchPath = (patchPath: unknown): boolean => + typeof patchPath === "string" && patchPath.startsWith("/shapes/"); const listPatchFiles = (root: string): string[] => { if (!fs.existsSync(root)) return []; @@ -115,11 +120,23 @@ const listPatchFiles = (root: string): string[] => { let patchFiles = 0; let staleOps = 0; const badPatches: string[] = []; +/** Smithy `/shapes/` ops, keyed by tag slug (`patches//…`). */ +const smithyPatchesBySlug = new Map< + string, + Array<{ rel: string; op: PatchFile["patches"][number] }> +>(); for (const rel of listPatchFiles(patchDir)) { const parsed = JSON.parse( fs.readFileSync(path.join(patchDir, rel), "utf-8"), ) as PatchFile; + const slug = rel.split("/")[0]!; for (const patchOp of parsed.patches ?? []) { + if (isSmithyPatchPath(patchOp.path)) { + const list = smithyPatchesBySlug.get(slug) ?? []; + list.push({ rel, op: patchOp }); + smithyPatchesBySlug.set(slug, list); + continue; + } try { applyOperation(fullSpec, patchOp); } catch (e) { @@ -386,6 +403,20 @@ for (const slug of [...tagBuckets.keys()].sort()) { ); } + for (const { rel, op: patchOp } of smithyPatchesBySlug.get(slug) ?? []) { + try { + applyOperation(model, patchOp); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (isStaleTargetError(msg)) { + staleOps++; + console.warn(` ⚠️ stale: ${rel} [${patchOp.op} ${patchOp.path}]`); + } else { + badPatches.push(`${rel} [${patchOp.op} ${patchOp.path}]: ${msg}`); + } + } + } + fs.writeFileSync( path.join(outDir, `${slug}.json`), JSON.stringify(model, null, 2) + "\n", @@ -406,6 +437,12 @@ if (emptyBuckets.length) { ` (${emptyBuckets.length} tag(s) dropped — every operation deprecated: ${emptyBuckets.join(", ")})`, ); } +if (badPatches.length) { + for (const b of badPatches) console.error(`❌ bad patch: ${b}`); + throw new Error( + `${badPatches.length} malformed patch operation(s) — fix or remove them`, + ); +} console.log( `✅ ${written} Smithy models (${totalOps} operations, ${totalPaginated} paginated) → ${outDir}`, ); diff --git a/packages/hetzner/src/services/servers.ts b/packages/hetzner/src/services/servers.ts index 44f6a1ed2..78afbf0ea 100644 --- a/packages/hetzner/src/services/servers.ts +++ b/packages/hetzner/src/services/servers.ts @@ -14,6 +14,29 @@ import * as Retry from "../retry.ts"; export type { HetznerOpError, HetznerOpContext }; +/** The project has no remaining Server quota (Hetzner `resource_limit_exceeded`, HTTP 403). Often a race after a delete — retry. */ +export class ServerLimitExceeded + extends /*@__PURE__*/ T.applyErrorMatchers( + /*@__PURE__*/ S.TaggedError()("ServerLimitExceeded", { + code: S.Number, + message: S.String, + }), + [{ status: 403, message: { includes: "server limit" } }], + ) {} + +/** The Server could not be placed (Hetzner `resource_unavailable` / `placement_error`, HTTP 412). Spread groups and location capacity recover — retry. */ +export class ServerPlacementError + extends /*@__PURE__*/ T.applyErrorMatchers( + /*@__PURE__*/ S.TaggedError()( + "ServerPlacementError", + { + code: S.Number, + message: S.String, + }, + ), + [{ status: 412, message: { includes: "placement" } }], + ) {} + export type CreateServerRequestSshKeysItem = number | string; export const CreateServerRequestSshKeysItem = /*@__PURE__*/ S.Unknown as any as S.Schema; @@ -3233,7 +3256,10 @@ export const UpdateServerResponse = /*@__PURE__*/ S.suspend(() => identifier: "UpdateServerResponse", }) as any as S.Schema; -export type CreateServerError = HetznerOpError; +export type CreateServerError = + | ServerLimitExceeded + | ServerPlacementError + | HetznerOpError; /** Create a Server Creates a new Server. Returns preliminary information about the Server as well as an Action that covers progress of creation. #### Operation specific errors */ export const createServer: API.OperationMethod< CreateServerRequest, @@ -3243,7 +3269,7 @@ export const createServer: API.OperationMethod< > = /*@__PURE__*/ API.make(() => ({ input: CreateServerRequest, output: CreateServerResponse, - errors: [UnknownHetznerError], + errors: [ServerLimitExceeded, ServerPlacementError, UnknownHetznerError], protocol: HetznerProtocol, retry: Retry.Retry, })); diff --git a/packages/railway/.generated-specs/railway.json b/packages/railway/.generated-specs/railway.json index ac5927fd4..3207075ec 100644 --- a/packages/railway/.generated-specs/railway.json +++ b/packages/railway/.generated-specs/railway.json @@ -32460,7 +32460,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "mutation deploymentCancel($id: String!) {\n deploymentCancel(id: $id) {\n __typename\n }\n}", + "query": "mutation deploymentCancel($id: String!) {\n deploymentCancel(id: $id)\n}", "operationName": "deploymentCancel", "type": "mutation" } @@ -33032,7 +33032,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "mutation deploymentRemove($id: String!) {\n deploymentRemove(id: $id) {\n __typename\n }\n}", + "query": "mutation deploymentRemove($id: String!) {\n deploymentRemove(id: $id)\n}", "operationName": "deploymentRemove", "type": "mutation" } @@ -33085,7 +33085,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "mutation deploymentRestart($id: String!) {\n deploymentRestart(id: $id) {\n __typename\n }\n}", + "query": "mutation deploymentRestart($id: String!) {\n deploymentRestart(id: $id)\n}", "operationName": "deploymentRestart", "type": "mutation" } @@ -33191,7 +33191,7 @@ "traits": { "smithy.api#input": {}, "com.railway.graphql#operation": { - "query": "mutation deploymentStop($id: String!) {\n deploymentStop(id: $id) {\n __typename\n }\n}", + "query": "mutation deploymentStop($id: String!) {\n deploymentStop(id: $id)\n}", "operationName": "deploymentStop", "type": "mutation" } diff --git a/packages/railway/patches/railway/deploymentCancel.json b/packages/railway/patches/railway/deploymentCancel.json new file mode 100644 index 000000000..baa831fec --- /dev/null +++ b/packages/railway/patches/railway/deploymentCancel.json @@ -0,0 +1,10 @@ +{ + "description": "deploymentCancel returns Boolean! so the baked document must not select subfields.", + "patches": [ + { + "op": "replace", + "path": "/shapes/com.railway.api#DeploymentCancelRequest/traits/com.railway.graphql#operation/query", + "value": "mutation deploymentCancel($id: String!) {\n deploymentCancel(id: $id)\n}" + } + ] +} diff --git a/packages/railway/patches/railway/deploymentRemove.json b/packages/railway/patches/railway/deploymentRemove.json new file mode 100644 index 000000000..dad257b9a --- /dev/null +++ b/packages/railway/patches/railway/deploymentRemove.json @@ -0,0 +1,10 @@ +{ + "description": "deploymentRemove returns Boolean! so the baked document must not select subfields.", + "patches": [ + { + "op": "replace", + "path": "/shapes/com.railway.api#DeploymentRemoveRequest/traits/com.railway.graphql#operation/query", + "value": "mutation deploymentRemove($id: String!) {\n deploymentRemove(id: $id)\n}" + } + ] +} diff --git a/packages/railway/patches/railway/deploymentRestart.json b/packages/railway/patches/railway/deploymentRestart.json new file mode 100644 index 000000000..d8dd77ae4 --- /dev/null +++ b/packages/railway/patches/railway/deploymentRestart.json @@ -0,0 +1,10 @@ +{ + "description": "deploymentRestart returns Boolean! so the baked document must not select subfields.", + "patches": [ + { + "op": "replace", + "path": "/shapes/com.railway.api#DeploymentRestartRequest/traits/com.railway.graphql#operation/query", + "value": "mutation deploymentRestart($id: String!) {\n deploymentRestart(id: $id)\n}" + } + ] +} diff --git a/packages/railway/patches/railway/deploymentStop.json b/packages/railway/patches/railway/deploymentStop.json new file mode 100644 index 000000000..25bd2c797 --- /dev/null +++ b/packages/railway/patches/railway/deploymentStop.json @@ -0,0 +1,10 @@ +{ + "description": "deploymentStop returns Boolean! so the baked document must not select subfields.", + "patches": [ + { + "op": "replace", + "path": "/shapes/com.railway.api#DeploymentStopRequest/traits/com.railway.graphql#operation/query", + "value": "mutation deploymentStop($id: String!) {\n deploymentStop(id: $id)\n}" + } + ] +} diff --git a/packages/railway/src/protocol.ts b/packages/railway/src/protocol.ts index 10b36063f..a0f3bce46 100644 --- a/packages/railway/src/protocol.ts +++ b/packages/railway/src/protocol.ts @@ -116,7 +116,8 @@ const retryAfterForRateLimit = ( ) => { const fromHeader = parseRetryAfterForStatus(429, headers); if (fromHeader !== undefined) return fromHeader; - const match = /per (\d+) seconds/.exec(message); + const match = + /per (\d+) seconds/.exec(message) ?? /every (\d+)\s*s/.exec(message); if (match !== null) { return Duration.seconds(Number(match[1]) + 1); } @@ -233,6 +234,12 @@ const matchError = ( return fail(new UnknownRailwayError({ body: errorBody })); }; +/** Cloudflare's public GraphQL edge is 10 RPS; `source=` is an identified client. */ +const withClientSource = (url: string) => { + if (/[?&]source=/.test(url)) return url; + return url.includes("?") ? `${url}&source=alchemy` : `${url}?source=alchemy`; +}; + // ============================================================================ // Protocol // ============================================================================ @@ -283,7 +290,12 @@ const encode = ({ ? { "Project-Access-Token": token } : { Authorization: `Bearer ${token}` }; - const url = `${creds.apiBaseUrl}${http?.uri ?? "/graphql/v2"}`; + // Terraform's Railway provider hits + // `.../graphql/v2?source=terraform_provider_railway` so Cloudflare's + // 10 RPS edge limit on anonymous GraphQL does not apply. Same shape. + const url = withClientSource( + `${creds.apiBaseUrl}${http?.uri ?? "/graphql/v2"}`, + ); if (process.env.DISTILLED_DEBUG_HTTP) { console.error( `[distilled] POST ${url} op=${op.operationName} variables=${JSON.stringify(variables).slice(0, 400)}`, diff --git a/packages/railway/src/services/railway.ts b/packages/railway/src/services/railway.ts index 4134d0efd..83f890dde 100644 --- a/packages/railway/src/services/railway.ts +++ b/packages/railway/src/services/railway.ts @@ -3023,7 +3023,7 @@ export const DeploymentCancelRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "mutation deploymentCancel($id: String!) {\n deploymentCancel(id: $id) {\n __typename\n }\n}", + "mutation deploymentCancel($id: String!) {\n deploymentCancel(id: $id)\n}", operationName: "deploymentCancel", type: "mutation", }), @@ -3550,7 +3550,7 @@ export const DeploymentRemoveRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "mutation deploymentRemove($id: String!) {\n deploymentRemove(id: $id) {\n __typename\n }\n}", + "mutation deploymentRemove($id: String!) {\n deploymentRemove(id: $id)\n}", operationName: "deploymentRemove", type: "mutation", }), @@ -3577,7 +3577,7 @@ export const DeploymentRestartRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "mutation deploymentRestart($id: String!) {\n deploymentRestart(id: $id) {\n __typename\n }\n}", + "mutation deploymentRestart($id: String!) {\n deploymentRestart(id: $id)\n}", operationName: "deploymentRestart", type: "mutation", }), @@ -3966,7 +3966,7 @@ export const DeploymentStopRequest = /*@__PURE__*/ S.suspend(() => .pipe( T.GraphQLOp({ query: - "mutation deploymentStop($id: String!) {\n deploymentStop(id: $id) {\n __typename\n }\n}", + "mutation deploymentStop($id: String!) {\n deploymentStop(id: $id)\n}", operationName: "deploymentStop", type: "mutation", }),