Skip to content

Commit 532684a

Browse files
committed
Handle node fork failures; add InternalError type
1 parent 14eda60 commit 532684a

3 files changed

Lines changed: 73 additions & 7 deletions

File tree

apps/api/src/errors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ export class BillingLimitError extends Data.TaggedError('BillingLimitError')<{
5858
readonly message: string
5959
}> {}
6060

61+
export class InternalError extends Data.TaggedError('InternalError')<{
62+
readonly message: string
63+
}> {}
64+
6165
export type ApiError =
6266
| NotFoundError
6367
| UnauthorizedError
@@ -73,6 +77,7 @@ export type ApiError =
7377
| GoneError
7478
| NotImplementedError
7579
| BillingLimitError
80+
| InternalError
7681

7782
const STATUS_MAP: Record<ApiError['_tag'], number> = {
7883
NotFoundError: 404,
@@ -89,6 +94,7 @@ const STATUS_MAP: Record<ApiError['_tag'], number> = {
8994
GoneError: 410,
9095
NotImplementedError: 501,
9196
BillingLimitError: 403,
97+
InternalError: 500,
9298
}
9399

94100
const CODE_MAP: Record<ApiError['_tag'], string> = {
@@ -106,6 +112,7 @@ const CODE_MAP: Record<ApiError['_tag'], string> = {
106112
GoneError: 'gone',
107113
NotImplementedError: 'not_implemented',
108114
BillingLimitError: 'billing_limit',
115+
InternalError: 'internal_error',
109116
}
110117

111118
export function errorToResponse(error: ApiError, requestId: string) {

apps/api/src/routes/sandboxes.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,17 @@ import { idToBytes } from '@sandchest/contract'
3333
import type { CreateSandboxResponse, GetSandboxResponse, ReplayBundle } from '@sandchest/contract'
3434
import type { BufferedEvent } from '../services/redis.js'
3535
import { RUN_API_INTEGRATION_TESTS } from '../test-support.js'
36+
import type { NodeClientApi } from '../services/node-client.js'
3637

3738
const TEST_ORG = 'org_test_123'
3839
const TEST_USER = 'user_test_456'
3940

40-
function createTestEnv() {
41+
function createTestEnv(overrides?: { nodeClient?: NodeClientApi }) {
4142
const sandboxRepo = createInMemorySandboxRepo()
4243
const execRepo = createInMemoryExecRepo()
4344
const sessionRepo = createInMemorySessionRepo()
4445
const objectStorage = createInMemoryObjectStorage()
45-
const nodeClient = createInMemoryNodeClient()
46+
const nodeClient = overrides?.nodeClient ?? createInMemoryNodeClient()
4647
const redis = createInMemoryRedisApi()
4748
const artifactRepo = createInMemoryArtifactRepo()
4849
const quotaApi = createInMemoryQuotaApi()
@@ -777,6 +778,48 @@ describe.skipIf(!RUN_API_INTEGRATION_TESTS)('POST /v1/sandboxes/:id/fork — quo
777778
})
778779
})
779780

781+
describe.skipIf(!RUN_API_INTEGRATION_TESTS)('POST /v1/sandboxes/:id/fork — node failure handling', () => {
782+
test('returns internal_error with node message and marks fork failed', async () => {
783+
const baseNodeClient = createInMemoryNodeClient()
784+
const failingNodeClient: NodeClientApi = {
785+
...baseNodeClient,
786+
forkSandbox: () => Effect.die(new Error('simulated fork failure')),
787+
}
788+
const env = createTestEnv({ nodeClient: failingNodeClient })
789+
const parentId = await createRunningSandbox(env)
790+
791+
const result = await env.runTest(
792+
Effect.gen(function* () {
793+
const client = yield* HttpClient.HttpClient
794+
const response = yield* client.execute(
795+
HttpClientRequest.post(`/v1/sandboxes/${parentId}/fork`).pipe(
796+
HttpClientRequest.bodyUnsafeJson({}),
797+
),
798+
)
799+
const body = yield* response.json
800+
return { status: response.status, body: body as { error: string; message: string } }
801+
}),
802+
)
803+
804+
expect(result.status).toBe(500)
805+
expect(result.body.error).toBe('internal_error')
806+
expect(result.body.message).toContain('simulated fork failure')
807+
808+
const tree = await env.runTest(
809+
Effect.gen(function* () {
810+
const client = yield* HttpClient.HttpClient
811+
const response = yield* client.execute(
812+
HttpClientRequest.get(`/v1/sandboxes/${parentId}/forks`),
813+
)
814+
return (yield* response.json) as { tree: Array<{ status: string; failure_reason?: string | null }> }
815+
}),
816+
)
817+
818+
const failedFork = tree.tree.find((node) => node.status === 'failed')
819+
expect(failedFork).toBeDefined()
820+
})
821+
})
822+
780823
// ---------------------------------------------------------------------------
781824
// GET /v1/sandboxes/:id/stream — sandbox-level SSE
782825
// ---------------------------------------------------------------------------

apps/api/src/routes/sandboxes.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { HttpRouter, HttpServerRequest, HttpServerResponse } from '@effect/platform'
2-
import { Effect } from 'effect'
2+
import { Cause, Effect } from 'effect'
33
import {
44
generateUUIDv7,
55
idToBytes,
@@ -33,6 +33,7 @@ import {
3333
ForkDepthExceededError,
3434
ForkLimitExceededError,
3535
GoneError,
36+
InternalError,
3637
NotFoundError,
3738
QuotaExceededError,
3839
SandboxNotRunningError,
@@ -613,14 +614,29 @@ const forkSandbox = Effect.gen(function* () {
613614
ttlSeconds,
614615
})
615616

616-
// Increment parent's fork count
617-
yield* repo.incrementForkCount(sourceIdBytes, auth.orgId)
618-
619617
// Tell the node to fork the VM
620618
yield* nodeClient.forkSandbox({
621619
sourceSandboxId: sourceIdBytes,
622620
newSandboxId: forkId,
623-
})
621+
}).pipe(
622+
Effect.catchAllCause((cause) =>
623+
Effect.gen(function* () {
624+
yield* repo.updateStatus(forkId, auth.orgId, 'failed', {
625+
endedAt: new Date(),
626+
failureReason: 'provision_failed',
627+
})
628+
629+
return yield* Effect.fail(
630+
new InternalError({
631+
message: `Fork failed on node: ${Cause.pretty(cause)}`,
632+
}),
633+
)
634+
}),
635+
),
636+
)
637+
638+
// Increment parent's fork count only after the node confirms the fork succeeded.
639+
yield* repo.incrementForkCount(sourceIdBytes, auth.orgId)
624640

625641
const sandboxId = bytesToId(SANDBOX_PREFIX, forkRow.id)
626642
const response: ForkSandboxResponse = {

0 commit comments

Comments
 (0)