Skip to content

Commit a2ee88b

Browse files
asTerminalError to provide an easy mapping between domain model errors and Restate TerminalError (#562)
* TerminalError.cause should not be exposed. This commit deprecates it. * Add `asTerminalError` to let people map their domain model errors to `TerminalError` * Wireup + add example
1 parent 4fbdae7 commit a2ee88b

File tree

7 files changed

+108
-17
lines changed

7 files changed

+108
-17
lines changed

packages/restate-sdk-examples/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"verify": "npm run format-check && npm run lint && npm run build",
3030
"release": "",
3131
"object": "RESTATE_LOGGING=debug tsx ./src/object.ts",
32-
"greeter": "RESTATE_LOGGING=debug tsx ./src/greeter.ts",
32+
"greeter": "RESTATE_LOGGING=debug tsx ./src/greeter_with_options.ts",
3333
"zgreeter": "RESTATE_LOGGING=debug tsx ./src/zod_greeter.ts",
3434
"workflow": "RESTATE_LOGGING=debug tsx ./src/workflow.ts",
3535
"workflow_client": "RESTATE_LOGGING=debug tsx ./src/workflow_client.ts",

packages/restate-sdk-examples/src/greeter_with_options.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ import {
1313
service,
1414
endpoint,
1515
handlers,
16+
TerminalError,
1617
type Context,
1718
} from "@restatedev/restate-sdk";
1819

20+
class MyValidationError extends Error {}
21+
1922
const greeter = service({
2023
name: "greeter",
2124
handlers: {
@@ -24,12 +27,23 @@ const greeter = service({
2427
journalRetention: { days: 1 },
2528
},
2629
async (ctx: Context, name: string) => {
30+
if (name.length === 0) {
31+
throw new MyValidationError("Name length is 0");
32+
}
2733
return `Hello ${name}`;
2834
}
2935
),
3036
},
3137
options: {
3238
journalRetention: { days: 2 },
39+
asTerminalError: (err) => {
40+
if (err instanceof MyValidationError) {
41+
// My validation error is terminal
42+
return new TerminalError(err.message, { errorCode: 400 });
43+
}
44+
45+
// Any other error is retryable
46+
},
3347
},
3448
});
3549

packages/restate-sdk/src/context_impl.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ export class ContextImpl implements ObjectContext, WorkflowContext {
104104
private readonly invocationRequest: Request,
105105
private readonly invocationEndPromise: CompletablePromise<void>,
106106
inputReader: ReadableStreamDefaultReader<Uint8Array>,
107-
outputWriter: WritableStreamDefaultWriter<Uint8Array>
107+
outputWriter: WritableStreamDefaultWriter<Uint8Array>,
108+
private readonly asTerminalError?: (error: any) => TerminalError | undefined
108109
) {
109110
this.rand = new RandImpl(input.invocation_id, () => {
110111
// TODO reimplement this check with async context
@@ -422,7 +423,7 @@ export class ContextImpl implements ObjectContext, WorkflowContext {
422423
try {
423424
res = await action();
424425
} catch (e) {
425-
err = ensureError(e);
426+
err = ensureError(e, this.asTerminalError);
426427
}
427428
const attemptDuration = Date.now() - startTime;
428429

packages/restate-sdk/src/endpoint/handlers/generic.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
TerminalError,
1717
} from "../../types/errors.js";
1818
import type { ProtocolMode } from "../../types/discovery.js";
19-
import type { ComponentHandler } from "../../types/components.js";
19+
import type { Component, ComponentHandler } from "../../types/components.js";
2020
import { parseUrlComponents } from "../../types/components.js";
2121
import { X_RESTATE_SERVER } from "../../user_agent.js";
2222
import type { EndpointBuilder } from "../endpoint_builder.js";
@@ -168,13 +168,13 @@ export class GenericHandler implements RestateHandler {
168168
this.endpoint.rlog.warn(errorMessage);
169169
return this.toErrorResponse(415, errorMessage);
170170
}
171-
const method = this.endpoint.componentByName(parsed.componentName);
172-
if (!method) {
171+
const service = this.endpoint.componentByName(parsed.componentName);
172+
if (!service) {
173173
const msg = `No service found for URL: ${JSON.stringify(parsed)}`;
174174
this.endpoint.rlog.error(msg);
175175
return this.toErrorResponse(404, msg);
176176
}
177-
const handler = method?.handlerMatching(parsed);
177+
const handler = service?.handlerMatching(parsed);
178178
if (!handler) {
179179
const msg = `No service found for URL: ${JSON.stringify(parsed)}`;
180180
this.endpoint.rlog.error(msg);
@@ -187,6 +187,7 @@ export class GenericHandler implements RestateHandler {
187187
}
188188

189189
return this.handleInvoke(
190+
service,
190191
handler,
191192
request.body,
192193
request.headers,
@@ -225,6 +226,7 @@ export class GenericHandler implements RestateHandler {
225226
}
226227

227228
private async handleInvoke(
229+
service: Component,
228230
handler: ComponentHandler,
229231
body: ReadableStream<Uint8Array>,
230232
headers: Headers,
@@ -365,7 +367,8 @@ export class GenericHandler implements RestateHandler {
365367
invocationRequest,
366368
invocationEndPromise,
367369
inputReader,
368-
outputWriter
370+
outputWriter,
371+
service.options?.asTerminalError
369372
);
370373

371374
// Finally invoke user handler
@@ -377,7 +380,7 @@ export class GenericHandler implements RestateHandler {
377380
vmLogger.info("Invocation completed successfully.");
378381
})
379382
.catch((e) => {
380-
const error = ensureError(e);
383+
const error = ensureError(e, service.options?.asTerminalError);
381384
logError(vmLogger, error);
382385

383386
if (error instanceof TerminalError) {

packages/restate-sdk/src/types/components.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface Component {
3030
name(): string;
3131
handlerMatching(url: InvokePathComponents): ComponentHandler | undefined;
3232
discovery(): d.Service;
33+
options?: ServiceOptions | ObjectOptions | WorkflowOptions;
3334
}
3435

3536
export interface ComponentHandler {

packages/restate-sdk/src/types/errors.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,19 @@ export const UNKNOWN_ERROR_CODE = 500;
2020
export const CLOSED_ERROR_CODE = 598;
2121
export const SUSPENDED_ERROR_CODE = 599;
2222

23-
export function ensureError(e: unknown): Error {
23+
export function ensureError(
24+
e: unknown,
25+
asTerminalError?: (error: any) => TerminalError | undefined
26+
): Error {
27+
if (e instanceof TerminalError) {
28+
return e;
29+
}
30+
// Try convert to terminal error
31+
const maybeTerminalError = asTerminalError ? asTerminalError(e) : undefined;
32+
if (maybeTerminalError) {
33+
return maybeTerminalError;
34+
}
35+
2436
if (e instanceof Error) {
2537
return e;
2638
}
@@ -31,6 +43,7 @@ export function ensureError(e: unknown): Error {
3143
});
3244
}
3345

46+
// None of the types we know
3447
let msg;
3548
try {
3649
msg = JSON.stringify(e);
@@ -70,16 +83,34 @@ export class RestateError extends Error {
7083
}
7184
}
7285

73-
// Does not lead to Restate retries
74-
// Leads to an output message with a failure defined
86+
/**
87+
* Does not lead to Restate retries.
88+
*
89+
* Leads to an output message with a failure defined.
90+
*/
7591
export class TerminalError extends RestateError {
7692
public name = "TerminalError";
7793

78-
constructor(message: string, options?: { errorCode?: number; cause?: any }) {
94+
constructor(
95+
message: string,
96+
options?: {
97+
/**
98+
* Error code. This should be an HTTP status code, and in case the service was invoked from the ingress, this will be propagated back to the caller.
99+
*/
100+
errorCode?: number;
101+
/**
102+
* @deprecated YOU MUST NOT USE THIS FIELD, AS IT WON'T BE RECORDED AND CAN LEAD TO NON-DETERMINISM! From the next SDK version, the constructor won't accept this field anymore.
103+
*/
104+
cause?: any;
105+
}
106+
) {
79107
super(message, options);
80108
}
81109
}
82110

111+
/**
112+
* Returned by `RestatePromise.withTimeout` when the timeout is reached.
113+
*/
83114
export class TimeoutError extends TerminalError {
84115
public name = "TimeoutError";
85116

@@ -88,6 +119,9 @@ export class TimeoutError extends TerminalError {
88119
}
89120
}
90121

122+
/**
123+
* Returned when the invocation was cancelled.
124+
*/
91125
export class CancelledError extends TerminalError {
92126
public name = "CancelledError";
93127

packages/restate-sdk/src/types/rpc.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
type Duration,
3939
serde,
4040
} from "@restatedev/restate-sdk-core";
41-
import { TerminalError } from "./errors.js";
41+
import { ensureError, TerminalError } from "./errors.js";
4242

4343
// ----------- rpc clients -------------------------------------------------------
4444

@@ -452,7 +452,8 @@ export class HandlerWrapper {
452452
public readonly inactivityTimeout?: Duration | number,
453453
public readonly abortTimeout?: Duration | number,
454454
public readonly ingressPrivate?: boolean,
455-
public readonly enableLazyState?: boolean
455+
public readonly enableLazyState?: boolean,
456+
public readonly asTerminalError?: (error: any) => TerminalError | undefined
456457
) {
457458
this.accept = accept ? accept : inputSerde.contentType;
458459
this.contentType = outputSerde.contentType;
@@ -467,9 +468,9 @@ export class HandlerWrapper {
467468
try {
468469
req = this.inputSerde.deserialize(input);
469470
} catch (e) {
470-
throw new TerminalError(`Failed to deserialize input.`, {
471+
const error = ensureError(e);
472+
throw new TerminalError(`Failed to deserialize input: ${error.message}`, {
471473
errorCode: 400,
472-
cause: e,
473474
});
474475
}
475476
const res: unknown = await this.handler(context, req);
@@ -818,6 +819,43 @@ export type ServiceOptions = {
818819
* otherwise the service discovery will fail.
819820
*/
820821
ingressPrivate?: boolean;
822+
823+
/**
824+
* By default, Restate will consider any error terminal, that is non retryable, if it's an instance of `TerminalError`.
825+
*
826+
* By setting this field, you can provide a function to map specific errors in your domain to `TerminalError` (or undefined, if the error should be considered retryable). Once `TerminalError`, these errors won't be retried.
827+
*
828+
* Note: this will be used both for errors thrown by `ctx.run` closures and by errors thrown in restate handlers.
829+
*
830+
* Example:
831+
*
832+
* ```ts
833+
* class MyValidationError extends Error {}
834+
*
835+
* const greeter = restate.service({
836+
* name: "greeter",
837+
* handlers: {
838+
* greet: async (ctx: restate.Context, name: string) => {
839+
* if (name.length === 0) {
840+
* throw new MyValidationError("Length too short");
841+
* }
842+
* return `Hello ${name}`;
843+
* }
844+
* },
845+
* options: {
846+
* asTerminalError: (err) => {
847+
* if (err instanceof MyValidationError) {
848+
* // My validation error is terminal
849+
* return new restate.TerminalError(err.message, {errorCode: 400})
850+
* }
851+
*
852+
* // Any other error is retryable
853+
* }
854+
* }
855+
* });
856+
* ```
857+
*/
858+
asTerminalError?: (error: any) => TerminalError | undefined;
821859
};
822860

823861
/**

0 commit comments

Comments
 (0)