Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/custom-defect-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/rpc": minor
Comment thread
tim-smart marked this conversation as resolved.
Resolved
---

Add optional `defect` parameter to `Rpc.make` for customizing defect serialization per-RPC. Defaults to `Schema.Defect`, preserving existing behavior.
38 changes: 36 additions & 2 deletions packages/platform-node/test/RpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { HttpClient, HttpClientRequest, HttpRouter, HttpServer, SocketServer } f
import { NodeHttpServer, NodeSocket, NodeSocketServer, NodeWorker } from "@effect/platform-node"
import { RpcClient, RpcSerialization, RpcServer } from "@effect/rpc"
import { assert, describe, it } from "@effect/vitest"
import { Effect, Layer } from "effect"
import { Cause, Effect, Layer } from "effect"
import * as CP from "node:child_process"
import { RpcLive, User, UsersClient } from "./fixtures/rpc-schemas.js"
import { RpcLive, RpcLiveDisableFatalDefects, User, UsersClient } from "./fixtures/rpc-schemas.js"
import { e2eSuite } from "./rpc-e2e.js"

describe("RpcServer", () => {
Expand Down Expand Up @@ -148,4 +148,38 @@ describe("RpcServer", () => {
assert.deepStrictEqual(user, new User({ id: "1", name: "Logged in user" }))
}).pipe(Effect.provide(UsersClient.layerTest)))
})

describe("custom defect schema", () => {
const CustomDefectServer = HttpRouter.Default.serve().pipe(
Layer.provide(RpcLiveDisableFatalDefects),
Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))
)
const CustomDefectClient = UsersClient.layer.pipe(
Layer.provide(
RpcClient.layerProtocolHttp({
url: "",
transformClient: HttpClient.mapRequest(HttpClientRequest.appendUrl("/rpc"))
})
)
)
const CustomDefectLayer = CustomDefectClient.pipe(
Layer.provideMerge(CustomDefectServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerNdjson])
)

it.effect("preserves full defect with Schema.Unknown", () =>
Effect.gen(function*() {
const client = yield* UsersClient
const cause = yield* client.ProduceDefectCustom().pipe(
Effect.sandbox,
Effect.flip
)
const defect = Cause.squash(cause)
assert.deepStrictEqual(defect, {
message: "detailed error",
stack: "Error: detailed error\n at handler.ts:1",
code: 42
})
}).pipe(Effect.provide(CustomDefectLayer)))
})
})
17 changes: 17 additions & 0 deletions packages/platform-node/test/fixtures/rpc-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ export const UserRpcs = RpcGroup.make(
}),
Rpc.make("ProduceDefect"),
Rpc.make("ProduceErrorDefect"),
Rpc.make("ProduceDefectCustom", {
defect: Schema.Struct({
message: Schema.String,
stack: Schema.String,
code: Schema.Number
})
}),
Rpc.make("Never"),
Rpc.make("nested.test"),
Rpc.make("TimedMethod", {
Expand Down Expand Up @@ -134,6 +141,8 @@ const UsersLive = UserRpcs.toLayer(Effect.gen(function*() {
GetEmits: () => Effect.sync(() => emits),
ProduceDefect: () => Effect.die("boom"),
ProduceErrorDefect: () => Effect.die(new Error("error defect message")),
ProduceDefectCustom: () =>
Effect.die({ message: "detailed error", stack: "Error: detailed error\n at handler.ts:1", code: 42 }),
Never: () => Effect.never.pipe(Effect.onInterrupt(() => Effect.sync(() => interrupts++))),
"nested.test": () => Effect.void,
TimedMethod: (_) => _.shouldFail ? Effect.die("boom") : Effect.succeed(1),
Expand All @@ -154,6 +163,14 @@ export const RpcLive = RpcServer.layer(UserRpcs).pipe(
])
)

export const RpcLiveDisableFatalDefects = RpcServer.layer(UserRpcs, { disableFatalDefects: true }).pipe(
Layer.provide([
UsersLive,
AuthLive,
TimingLive
])
)

const AuthClient = RpcMiddleware.layerClient(AuthMiddleware, ({ request }) =>
Effect.succeed({
...request,
Expand Down
15 changes: 14 additions & 1 deletion packages/rpc/src/Rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface Rpc<
readonly payloadSchema: Payload
readonly successSchema: Success
readonly errorSchema: Error
readonly defectSchema: Schema.Schema.Any

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema will have to have no requirements as well

readonly annotations: Context_.Context<never>
readonly middlewares: ReadonlySet<Middleware>

Expand Down Expand Up @@ -171,6 +172,7 @@ export interface AnyWithProps {
readonly payloadSchema: AnySchema
readonly successSchema: Schema.Schema.Any
readonly errorSchema: Schema.Schema.All
readonly defectSchema: Schema.Schema.Any
Comment thread
tim-smart marked this conversation as resolved.
Resolved
readonly annotations: Context_.Context<never>
readonly middlewares: ReadonlySet<RpcMiddleware.TagClassAnyWithProps>
}
Expand Down Expand Up @@ -541,6 +543,7 @@ const Proto = {
payloadSchema: this.payloadSchema,
successSchema,
errorSchema: this.errorSchema,
defectSchema: this.defectSchema,
annotations: this.annotations,
middlewares: this.middlewares
})
Expand All @@ -551,6 +554,7 @@ const Proto = {
payloadSchema: this.payloadSchema,
successSchema: this.successSchema,
errorSchema,
defectSchema: this.defectSchema,
annotations: this.annotations,
middlewares: this.middlewares
})
Expand All @@ -561,6 +565,7 @@ const Proto = {
payloadSchema: Schema.isSchema(payloadSchema) ? payloadSchema as any : Schema.Struct(payloadSchema as any),
successSchema: this.successSchema,
errorSchema: this.errorSchema,
defectSchema: this.defectSchema,
annotations: this.annotations,
middlewares: this.middlewares
})
Expand All @@ -571,6 +576,7 @@ const Proto = {
payloadSchema: this.payloadSchema,
successSchema: this.successSchema,
errorSchema: this.errorSchema,
defectSchema: this.defectSchema,
annotations: this.annotations,
middlewares: new Set([...this.middlewares, middleware])
})
Expand All @@ -581,6 +587,7 @@ const Proto = {
payloadSchema: this.payloadSchema,
successSchema: this.successSchema,
errorSchema: this.errorSchema,
defectSchema: this.defectSchema,
annotations: this.annotations,
middlewares: this.middlewares
})
Expand All @@ -591,6 +598,7 @@ const Proto = {
payloadSchema: this.payloadSchema,
successSchema: this.successSchema,
errorSchema: this.errorSchema,
defectSchema: this.defectSchema,
middlewares: this.middlewares,
annotations: Context_.add(this.annotations, tag, value)
})
Expand All @@ -601,6 +609,7 @@ const Proto = {
payloadSchema: this.payloadSchema,
successSchema: this.successSchema,
errorSchema: this.errorSchema,
defectSchema: this.defectSchema,
middlewares: this.middlewares,
annotations: Context_.merge(this.annotations, context)
})
Expand All @@ -618,6 +627,7 @@ const makeProto = <
readonly payloadSchema: Payload
readonly successSchema: Success
readonly errorSchema: Error
readonly defectSchema: Schema.Schema.Any
readonly annotations: Context_.Context<never>
readonly middlewares: ReadonlySet<Middleware>
}): Rpc<Tag, Payload, Success, Error, Middleware> => {
Expand All @@ -643,6 +653,7 @@ export const make = <
readonly success?: Success
readonly error?: Error
readonly stream?: Stream
readonly defect?: Schema.Schema.Any
readonly primaryKey?: [Payload] extends [Schema.Struct.Fields] ?
((payload: Schema.Simplify<Schema.Struct.Type<NoInfer<Payload>>>) => string) :
never
Expand Down Expand Up @@ -678,6 +689,7 @@ export const make = <
}) :
successSchema,
errorSchema: options?.stream ? Schema.Never : errorSchema,
defectSchema: options?.defect ?? Schema.Defect,
annotations: Context_.empty(),
middlewares: new Set<never>()
}) as any
Expand Down Expand Up @@ -719,6 +731,7 @@ export const fromTaggedRequest = <S extends AnyTaggedRequestSchema>(
payloadSchema: schema as any,
successSchema: schema.success as any,
errorSchema: schema.failure,
defectSchema: Schema.Defect,
annotations: Context_.empty(),
middlewares: new Set()
})
Expand Down Expand Up @@ -747,7 +760,7 @@ export const exitSchema = <R extends Any>(
const schema = Schema.Exit({
success: Option.isSome(streamSchemas) ? Schema.Void : rpc.successSchema,
failure: Schema.Union(...failures),
defect: Schema.Defect
defect: rpc.defectSchema
})
exitSchemaCache.set(self, schema)
return schema as any
Expand Down
39 changes: 26 additions & 13 deletions packages/rpc/src/RpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ export const make: <Rpcs extends Rpc.Any>(
return handleEncode(
client,
response.requestId,
schemas.encodeDefect,
schemas.collector,
Effect.provide(schemas.encodeChunk(response.values), schemas.context),
(values) => ({ _tag: "Chunk", requestId: String(response.requestId), values })
Expand All @@ -522,6 +523,7 @@ export const make: <Rpcs extends Rpc.Any>(
return handleEncode(
client,
response.requestId,
schemas.encodeDefect,
schemas.collector,
Effect.provide(schemas.encodeExit(response.exit), schemas.context),
(exit) => ({ _tag: "Exit", requestId: String(response.requestId), exit })
Expand Down Expand Up @@ -552,6 +554,7 @@ export const make: <Rpcs extends Rpc.Any>(
readonly decode: (u: unknown) => Effect.Effect<Rpc.Payload<Rpcs>, ParseError>
readonly encodeChunk: (u: ReadonlyArray<unknown>) => Effect.Effect<NonEmptyReadonlyArray<unknown>, ParseError>
readonly encodeExit: (u: unknown) => Effect.Effect<Schema.ExitEncoded<unknown, unknown, unknown>, ParseError>
readonly encodeDefect: (u: unknown) => Effect.Effect<unknown, ParseError>
readonly context: Context.Context<never>
readonly collector?: Transferable.CollectorService | undefined
}
Expand All @@ -568,6 +571,7 @@ export const make: <Rpcs extends Rpc.Any>(
Schema.Array(Option.isSome(streamSchemas) ? streamSchemas.value.success : Schema.Any)
) as any,
encodeExit: Schema.encodeUnknown(Rpc.exitSchema(rpc as any)) as any,
encodeDefect: Schema.encodeUnknown(rpc.defectSchema) as any,
context: entry.context
}
schemasCache.set(rpc, schemas)
Expand All @@ -584,6 +588,7 @@ export const make: <Rpcs extends Rpc.Any>(
const handleEncode = <A, R>(
client: Client,
requestId: RequestId,
encodeDefect: (u: unknown) => Effect.Effect<unknown, ParseError>,
collector: Transferable.CollectorService | undefined,
effect: Effect.Effect<A, ParseError, R>,
onSuccess: (a: A) => FromServerEncoded
Expand All @@ -594,27 +599,34 @@ export const make: <Rpcs extends Rpc.Any>(
client.schemas.delete(requestId)
const defect = Cause.squash(Cause.map(cause, TreeFormatter.formatErrorSync))
return Effect.zipRight(
sendRequestDefect(client, requestId, defect),
sendRequestDefect(client, requestId, encodeDefect, defect),
server.write(client.id, { _tag: "Interrupt", requestId, interruptors: [] })
)
})
)

const encodeDefect = Schema.encodeSync(Schema.Defect)

const sendRequestDefect = (client: Client, requestId: RequestId, defect: unknown) =>
const sendRequestDefect = (
client: Client,
requestId: RequestId,
encodeDefect: (u: unknown) => Effect.Effect<unknown, ParseError>,
defect: unknown
) =>
Effect.catchAllCause(
send(client.id, {
_tag: "Exit",
requestId: String(requestId),
exit: {
_tag: "Failure",
cause: {
_tag: "Die",
defect: encodeDefect(defect)
encodeDefect(defect).pipe(Effect.flatMap((encodedDefect) =>
send(client.id, {
_tag: "Exit",
requestId: String(requestId),
exit: {
_tag: "Failure",
cause: {
_tag: "Die",
defect: encodedDefect
}
}
}
}),
})
)),
(cause) => sendDefect(client, Cause.squash(cause))
)

Expand Down Expand Up @@ -661,7 +673,8 @@ export const make: <Rpcs extends Rpc.Any>(
return Effect.matchEffect(
Effect.provide(schemas.decode(request.payload), schemas.context),
{
onFailure: (error) => sendRequestDefect(client, requestId, TreeFormatter.formatErrorSync(error)),
onFailure: (error) =>
sendRequestDefect(client, requestId, schemas.encodeDefect, TreeFormatter.formatErrorSync(error)),
onSuccess: (payload) => {
client.schemas.set(
requestId,
Expand Down
29 changes: 28 additions & 1 deletion packages/rpc/test/Rpc.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Headers } from "@effect/platform"
import { Rpc, RpcGroup } from "@effect/rpc"
import { assert, describe, it } from "@effect/vitest"
import { Effect, Schema } from "effect"
import { Cause, Effect, Exit, Schema } from "effect"

const TestGroup = RpcGroup.make(
Rpc.make("one"),
Expand All @@ -20,4 +20,31 @@ describe("Rpc", () => {
const result = yield* handler(void 0, Headers.empty)
assert.strictEqual(result, "two")
}))

it("exitSchema uses custom defect schema", () => {
const DetailedDefect = Schema.Struct({
message: Schema.String,
stack: Schema.String,
code: Schema.Number
})

const myRpc = Rpc.make("customDefect", {
success: Schema.String,
defect: DetailedDefect
})

const schema = Rpc.exitSchema(myRpc)
const encode = Schema.encodeSync(schema)
const decode = Schema.decodeSync(schema)

const error = { message: "boom", stack: "Error: boom\n at foo.ts:1", code: 42 }
const exit = Exit.die(error)

// With a custom defect schema, structured error info survives the round-trip
const roundTripped = decode(encode(exit))

assert.isTrue(Exit.isFailure(roundTripped))
const defect = Cause.squash((roundTripped as Exit.Failure<any, any>).cause)
assert.deepStrictEqual(defect, error)
})
})