UNPKG

@temporalio/common

Version:

Common library for code that's used across the Client, Worker, and/or Workflow

511 lines (481 loc) 18.9 kB
import * as nexus from 'nexus-rpc'; import Long from 'long'; import type { temporal } from '@temporalio/proto'; import { ActivityFailure, ApplicationFailure, CancelledFailure, ChildWorkflowFailure, decodeApplicationFailureCategory, decodeRetryState, decodeTimeoutType, encodeApplicationFailureCategory, encodeRetryState, encodeTimeoutType, FAILURE_SOURCE, NexusOperationFailure, ProtoFailure, ServerFailure, TemporalFailure, TerminatedFailure, TimeoutFailure, } from '../failure'; import { makeProtoEnumConverters } from '../internal-workflow'; import { isError } from '../type-helpers'; import { msOptionalToTs } from '../time'; import { encode } from '../encoding'; import { arrayFromPayloads, fromPayloadsAtIndex, PayloadConverter, toPayloads } from './payload-converter'; // Can't import proto enums into the workflow sandbox, use this helper type and enum converter instead. const NexusHandlerErrorRetryBehavior = { RETRYABLE: 'RETRYABLE', NON_RETRYABLE: 'NON_RETRYABLE', } as const; type NexusHandlerErrorRetryBehavior = (typeof NexusHandlerErrorRetryBehavior)[keyof typeof NexusHandlerErrorRetryBehavior]; const [encodeNexusHandlerErrorRetryBehavior, decodeNexusHandlerErrorRetryBehavior] = makeProtoEnumConverters< temporal.api.enums.v1.NexusHandlerErrorRetryBehavior, typeof temporal.api.enums.v1.NexusHandlerErrorRetryBehavior, keyof typeof temporal.api.enums.v1.NexusHandlerErrorRetryBehavior, typeof NexusHandlerErrorRetryBehavior, 'NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_' >( { UNSPECIFIED: 0, [NexusHandlerErrorRetryBehavior.RETRYABLE]: 1, [NexusHandlerErrorRetryBehavior.NON_RETRYABLE]: 2, } as const, 'NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_' ); function combineRegExp(...regexps: RegExp[]): RegExp { return new RegExp(regexps.map((x) => `(?:${x.source})`).join('|')); } /** * Stack traces will be cutoff when on of these patterns is matched */ const CUTOFF_STACK_PATTERNS = combineRegExp( /** Activity execution */ /\s+at (Activity\.)?execute \(.*[\\/]worker[\\/](?:src|lib)[\\/]activity\.[jt]s:\d+:\d+\)/, /** Nexus execution */ /\s+at( async)? (NexusHandler\.)?invokeUserCode \(.*[\\/]worker[\\/](?:src|lib)[\\/]nexus[\\/]index\.[jt]s:\d+:\d+\)/, /** Workflow activation (inbound handlers only) */ /\s+at( async)? (Activator\.)?(startWorkflow|queryWorkflow|signalWorkflow|update|validateUpdate)NextHandler \(.*\.[jt]s:\d+:\d+\)/, /** Workflow run anything in context */ /\s+at (Script\.)?runInContext \(native|unknown|(?:(?:node:vm|vm\.js):\d+:\d+)\)/ ); /** * Any stack trace frames that match any of those wil be dopped. * The "null." prefix on some cases is to avoid https://github.com/nodejs/node/issues/42417 */ const DROPPED_STACK_FRAMES_PATTERNS = combineRegExp( /** Internal functions used to recursively chain interceptors */ /\s+at (null\.)?next \(.*[\\/]common[\\/](?:src|lib)[\\/]interceptors\.[jt]s:\d+:\d+\)/, /** Internal functions used to recursively chain interceptors */ /\s+at (null\.)?executeNextHandler \(.*[\\/]worker[\\/](?:src|lib)[\\/]activity\.[jt]s:\d+:\d+\)/ ); /** * Cuts out the framework part of a stack trace, leaving only user code entries */ export function cutoffStackTrace(stack?: string): string { const lines = (stack ?? '').split(/\r?\n/); const acc = Array<string>(); for (const line of lines) { if (CUTOFF_STACK_PATTERNS.test(line)) break; if (!DROPPED_STACK_FRAMES_PATTERNS.test(line)) acc.push(line); } return acc.join('\n'); } /** * A `FailureConverter` is responsible for converting from proto `Failure` instances to JS `Errors` and back. * * We recommended using the {@link DefaultFailureConverter} instead of customizing the default implementation in order * to maintain cross-language Failure serialization compatibility. */ export interface FailureConverter { /** * Converts a caught error to a Failure proto message. */ errorToFailure(err: unknown, payloadConverter: PayloadConverter): ProtoFailure; /** * Converts a Failure proto message to a JS Error object. * * The returned error must be an instance of `TemporalFailure`. */ failureToError(err: ProtoFailure, payloadConverter: PayloadConverter): Error; } /** * The "shape" of the attributes set as the {@link ProtoFailure.encodedAttributes} payload in case * {@link DefaultEncodedFailureAttributes.encodeCommonAttributes} is set to `true`. */ export interface DefaultEncodedFailureAttributes { message: string; stack_trace: string; } /** * Options for the {@link DefaultFailureConverter} constructor. */ export interface DefaultFailureConverterOptions { /** * Whether to encode error messages and stack traces (for encrypting these attributes use a {@link PayloadCodec}). */ encodeCommonAttributes: boolean; } /** * Default, cross-language-compatible Failure converter. * * By default, it will leave error messages and stack traces as plain text. In order to encrypt them, set * `encodeCommonAttributes` to `true` in the constructor options and use a {@link PayloadCodec} that can encrypt / * decrypt Payloads in your {@link WorkerOptions.dataConverter | Worker} and * {@link ClientOptions.dataConverter | Client options}. */ export class DefaultFailureConverter implements FailureConverter { public readonly options: DefaultFailureConverterOptions; constructor(options?: Partial<DefaultFailureConverterOptions>) { const { encodeCommonAttributes } = options ?? {}; this.options = { encodeCommonAttributes: encodeCommonAttributes ?? false, }; } /** * Converts a Failure proto message to a JS Error object. * * Does not set common properties, that is done in {@link failureToError}. */ failureToErrorInner(failure: ProtoFailure, payloadConverter: PayloadConverter): Error { if (failure.applicationFailureInfo) { return new ApplicationFailure( failure.message ?? undefined, failure.applicationFailureInfo.type, Boolean(failure.applicationFailureInfo.nonRetryable), arrayFromPayloads(payloadConverter, failure.applicationFailureInfo.details?.payloads), this.optionalFailureToOptionalError(failure.cause, payloadConverter), undefined, decodeApplicationFailureCategory(failure.applicationFailureInfo.category) ); } if (failure.serverFailureInfo) { return new ServerFailure( failure.message ?? undefined, Boolean(failure.serverFailureInfo.nonRetryable), this.optionalFailureToOptionalError(failure.cause, payloadConverter) ); } if (failure.timeoutFailureInfo) { return new TimeoutFailure( failure.message ?? undefined, fromPayloadsAtIndex(payloadConverter, 0, failure.timeoutFailureInfo.lastHeartbeatDetails?.payloads), decodeTimeoutType(failure.timeoutFailureInfo.timeoutType) ); } if (failure.terminatedFailureInfo) { return new TerminatedFailure( failure.message ?? undefined, this.optionalFailureToOptionalError(failure.cause, payloadConverter) ); } if (failure.canceledFailureInfo) { return new CancelledFailure( failure.message ?? undefined, arrayFromPayloads(payloadConverter, failure.canceledFailureInfo.details?.payloads), this.optionalFailureToOptionalError(failure.cause, payloadConverter) ); } if (failure.resetWorkflowFailureInfo) { return new ApplicationFailure( failure.message ?? undefined, 'ResetWorkflow', false, arrayFromPayloads(payloadConverter, failure.resetWorkflowFailureInfo.lastHeartbeatDetails?.payloads), this.optionalFailureToOptionalError(failure.cause, payloadConverter) ); } if (failure.childWorkflowExecutionFailureInfo) { const { namespace, workflowType, workflowExecution, retryState } = failure.childWorkflowExecutionFailureInfo; if (!(workflowType?.name && workflowExecution)) { throw new TypeError('Missing attributes on childWorkflowExecutionFailureInfo'); } return new ChildWorkflowFailure( namespace ?? undefined, workflowExecution, workflowType.name, decodeRetryState(retryState), this.optionalFailureToOptionalError(failure.cause, payloadConverter) ); } if (failure.activityFailureInfo) { if (!failure.activityFailureInfo.activityType?.name) { throw new TypeError('Missing activityType?.name on activityFailureInfo'); } return new ActivityFailure( failure.message ?? undefined, failure.activityFailureInfo.activityType.name, failure.activityFailureInfo.activityId ?? undefined, decodeRetryState(failure.activityFailureInfo.retryState), failure.activityFailureInfo.identity ?? undefined, this.optionalFailureToOptionalError(failure.cause, payloadConverter) ); } if (failure.nexusHandlerFailureInfo) { let retryableOverride: boolean | undefined = undefined; const retryBehavior = decodeNexusHandlerErrorRetryBehavior(failure.nexusHandlerFailureInfo.retryBehavior); switch (retryBehavior) { case 'RETRYABLE': retryableOverride = true; break; case 'NON_RETRYABLE': retryableOverride = false; break; } const rawErrorType = failure.nexusHandlerFailureInfo.type || ''; const resolvedType: nexus.HandlerErrorType = Object.hasOwn(nexus.HandlerErrorType, rawErrorType) ? nexus.HandlerErrorType[rawErrorType as keyof typeof nexus.HandlerErrorType] : 'UNKNOWN'; return new nexus.HandlerError(resolvedType, failure.message ?? 'Nexus handler error', { cause: this.optionalFailureToOptionalError(failure.cause, payloadConverter), retryableOverride, rawErrorType, originalFailure: this.temporalFailureToNexusFailure(failure), }); } if (failure.nexusOperationExecutionFailureInfo) { return new NexusOperationFailure( // TODO(nexus/error): Maybe set a default message here, once we've decided on error handling. failure.message ?? undefined, failure.nexusOperationExecutionFailureInfo.scheduledEventId?.toNumber(), // We assume these will always be set or gracefully set to empty strings. failure.nexusOperationExecutionFailureInfo.endpoint ?? '', failure.nexusOperationExecutionFailureInfo.service ?? '', failure.nexusOperationExecutionFailureInfo.operation ?? '', failure.nexusOperationExecutionFailureInfo.operationToken ?? undefined, this.optionalFailureToOptionalError(failure.cause, payloadConverter) ); } return new TemporalFailure( failure.message ?? undefined, this.optionalFailureToOptionalError(failure.cause, payloadConverter) ); } failureToError(failure: ProtoFailure, payloadConverter: PayloadConverter): Error { if (failure.encodedAttributes) { const attrs = payloadConverter.fromPayload<DefaultEncodedFailureAttributes>(failure.encodedAttributes); // Don't apply encodedAttributes unless they conform to an expected schema if (typeof attrs === 'object' && attrs !== null) { const { message, stack_trace } = attrs; // Avoid mutating the argument failure = { ...failure }; if (typeof message === 'string') { failure.message = message; } if (typeof stack_trace === 'string') { failure.stackTrace = stack_trace; } } } const err = this.failureToErrorInner(failure, payloadConverter); err.stack = failure.stackTrace ?? ''; if (err instanceof TemporalFailure) { err.failure = failure; } return err; } errorToFailure(err: unknown, payloadConverter: PayloadConverter): ProtoFailure { const failure = this.errorToFailureInner(err, payloadConverter); if (this.options.encodeCommonAttributes) { const { message, stackTrace } = failure; failure.message = 'Encoded failure'; failure.stackTrace = ''; failure.encodedAttributes = payloadConverter.toPayload({ message, stack_trace: stackTrace }); } return failure; } errorToFailureInner(err: unknown, payloadConverter: PayloadConverter): ProtoFailure { if (err instanceof TemporalFailure || err instanceof nexus.HandlerError) { if (err instanceof TemporalFailure && err.failure) return err.failure; const base = { message: err.message, stackTrace: cutoffStackTrace(err.stack), cause: this.optionalErrorToOptionalFailure(err.cause, payloadConverter), source: FAILURE_SOURCE, }; if (err instanceof ActivityFailure) { return { ...base, activityFailureInfo: { ...err, retryState: encodeRetryState(err.retryState), activityType: { name: err.activityType }, }, }; } if (err instanceof ChildWorkflowFailure) { return { ...base, childWorkflowExecutionFailureInfo: { ...err, retryState: encodeRetryState(err.retryState), workflowExecution: err.execution, workflowType: { name: err.workflowType }, }, }; } if (err instanceof ApplicationFailure) { return { ...base, applicationFailureInfo: { type: err.type, nonRetryable: err.nonRetryable, details: err.details && err.details.length ? { payloads: toPayloads(payloadConverter, ...err.details) } : undefined, nextRetryDelay: msOptionalToTs(err.nextRetryDelay), category: encodeApplicationFailureCategory(err.category), }, }; } if (err instanceof CancelledFailure) { return { ...base, canceledFailureInfo: { details: err.details && err.details.length ? { payloads: toPayloads(payloadConverter, ...err.details) } : undefined, }, }; } if (err instanceof TimeoutFailure) { return { ...base, timeoutFailureInfo: { timeoutType: encodeTimeoutType(err.timeoutType), lastHeartbeatDetails: err.lastHeartbeatDetails ? { payloads: toPayloads(payloadConverter, err.lastHeartbeatDetails) } : undefined, }, }; } if (err instanceof ServerFailure) { return { ...base, serverFailureInfo: { nonRetryable: err.nonRetryable }, }; } if (err instanceof TerminatedFailure) { return { ...base, terminatedFailureInfo: {}, }; } if (err instanceof nexus.HandlerError) { if (err.originalFailure) { return this.nexusFailureToTemporalFailure(err.originalFailure, err.retryable); } else { let retryBehavior: temporal.api.enums.v1.NexusHandlerErrorRetryBehavior | undefined = undefined; switch (err.retryableOverride) { case true: retryBehavior = encodeNexusHandlerErrorRetryBehavior('RETRYABLE'); break; case false: retryBehavior = encodeNexusHandlerErrorRetryBehavior('NON_RETRYABLE'); break; } return { ...base, nexusHandlerFailureInfo: { type: err.type, retryBehavior, }, }; } } if (err instanceof NexusOperationFailure) { return { ...base, nexusOperationExecutionFailureInfo: { scheduledEventId: err.scheduledEventId ? Long.fromNumber(err.scheduledEventId) : undefined, endpoint: err.endpoint, service: err.service, operation: err.operation, operationToken: err.operationToken, }, }; } // Just a TemporalFailure return base; } const base = { source: FAILURE_SOURCE, }; if (isError(err)) { return { ...base, message: String(err.message ?? ''), stackTrace: cutoffStackTrace(err.stack), cause: this.optionalErrorToOptionalFailure((err as any).cause, payloadConverter), }; } const recommendation = ` [A non-Error value was thrown from your code. We recommend throwing Error objects so that we can provide a stack trace]`; if (typeof err === 'string') { return { ...base, message: err + recommendation }; } if (typeof err === 'object') { let message = ''; try { message = JSON.stringify(err); } catch (_err) { message = String(err); } return { ...base, message: message + recommendation }; } return { ...base, message: String(err) + recommendation }; } /** * Converts a Failure proto message to a JS Error object if defined or returns undefined. */ optionalFailureToOptionalError( failure: ProtoFailure | undefined | null, payloadConverter: PayloadConverter ): Error | undefined { return failure ? this.failureToError(failure, payloadConverter) : undefined; } /** * Converts an error to a Failure proto message if defined or returns undefined */ optionalErrorToOptionalFailure(err: unknown, payloadConverter: PayloadConverter): ProtoFailure | undefined { return err ? this.errorToFailure(err, payloadConverter) : undefined; } private nexusFailureToTemporalFailure(failure: nexus.Failure, retryable: boolean): ProtoFailure { if (failure.metadata?.type === 'temporal.api.failure.v1.Failure') { if (failure.details == null) { throw new TypeError("missing details for Nexus Failure of type 'temporal.api.failure.v1.Failure'"); } return failure.details; } else { const temporalFailure: ProtoFailure = {}; temporalFailure.applicationFailureInfo = { type: 'NexusFailure', nonRetryable: !retryable, details: { payloads: [ { metadata: { encoding: encode('json/plain') }, data: encode(JSON.stringify({ ...failure, message: '' })), }, ], }, }; temporalFailure.message = failure.message; temporalFailure.stackTrace = failure.stackTrace ?? ''; return temporalFailure; } } private temporalFailureToNexusFailure(failure: ProtoFailure): nexus.Failure { return { message: failure.message ?? '', metadata: { type: 'temporal.api.failure.v1.Failure' }, // Store the full ProtoFailure as the Nexus failure details so it can be round-tripped // losslessly back to a ProtoFailure via nexusFailureToTemporalFailure. details: { ...failure }, }; } }