diff --git a/packages/client/src/runtime/RequestHandler.ts b/packages/client/src/runtime/RequestHandler.ts index fc9966422a34..9756a7c22800 100644 --- a/packages/client/src/runtime/RequestHandler.ts +++ b/packages/client/src/runtime/RequestHandler.ts @@ -208,11 +208,19 @@ export class RequestHandler { message = this.sanitizeMessage(message) // TODO: Do request with callsite instead, so we don't need to rethrow if (error.code) { - throw new PrismaClientKnownRequestError(message, error.code, this.client._clientVersion, error.meta) + throw new PrismaClientKnownRequestError(message, { + code: error.code, + clientVersion: this.client._clientVersion, + meta: error.meta, + batchRequestIdx: error.batchRequestIdx, + }) } else if (error.isPanic) { throw new PrismaClientRustPanicError(message, this.client._clientVersion) } else if (error instanceof PrismaClientUnknownRequestError) { - throw new PrismaClientUnknownRequestError(message, this.client._clientVersion) + throw new PrismaClientUnknownRequestError(message, { + clientVersion: this.client._clientVersion, + batchRequestIdx: error.batchRequestIdx, + }) } else if (error instanceof PrismaClientInitializationError) { throw new PrismaClientInitializationError(message, this.client._clientVersion) } else if (error instanceof PrismaClientRustPanicError) { diff --git a/packages/client/src/runtime/getPrismaClient.ts b/packages/client/src/runtime/getPrismaClient.ts index 3fc7f67fa5a5..193839ecfeb9 100644 --- a/packages/client/src/runtime/getPrismaClient.ts +++ b/packages/client/src/runtime/getPrismaClient.ts @@ -56,6 +56,7 @@ import type { InstanceRejectOnNotFound, RejectOnNotFound } from './utils/rejectO import { getRejectOnNotFound } from './utils/rejectOnNotFound' import { serializeRawParameters } from './utils/serializeRawParameters' import { validatePrismaClientOptions } from './utils/validatePrismaClientOptions' +import { waitForBatch } from './utils/waitForBatch' const debug = Debug('prisma:client') const ALTER_RE = /^(\s*alter\s)/i @@ -957,7 +958,7 @@ new PrismaClient({ return request.requestTransaction?.({ id: txId, isolationLevel: options?.isolationLevel }, lock) }) - return Promise.all(requests) + return waitForBatch(requests) } /** diff --git a/packages/client/src/runtime/utils/rejectOnNotFound.ts b/packages/client/src/runtime/utils/rejectOnNotFound.ts index e75384aa7dcd..5fd3f7274f5f 100644 --- a/packages/client/src/runtime/utils/rejectOnNotFound.ts +++ b/packages/client/src/runtime/utils/rejectOnNotFound.ts @@ -17,7 +17,7 @@ export type InstanceRejectOnNotFound = */ export class NotFoundError extends PrismaClientKnownRequestError { constructor(message: string) { - super(message, 'P2025', clientVersion) + super(message, { code: 'P2025', clientVersion }) this.name = 'NotFoundError' } } diff --git a/packages/client/src/runtime/utils/waitForBatch.ts b/packages/client/src/runtime/utils/waitForBatch.ts new file mode 100644 index 000000000000..3eceefef1820 --- /dev/null +++ b/packages/client/src/runtime/utils/waitForBatch.ts @@ -0,0 +1,36 @@ +import { hasBatchIndex } from '@prisma/engine-core' + +/** + * Waits for result of batch $transaction and picks the best possible error to report if any + * of the request fails. Best error is determined as follows: + * + * - if engine have reported and index of failed request in the batch, the best error is the one + * who's index matches that + * - otherwise, first error is used (like Promise.all) + * + * @param promises + * @returns + */ +export async function waitForBatch(promises: T): Promise<{ [K in keyof T]: Awaited }> { + const results = await Promise.allSettled(promises) + + let bestError: unknown = null + const successfulResults = [] as { [K in keyof T]: Awaited } + for (const [i, result] of results.entries()) { + if (result.status === 'rejected') { + const reason = result.reason + if (hasBatchIndex(reason) && reason.batchRequestIdx === i) { + bestError = reason + } else if (!bestError) { + bestError = reason + } + } else { + successfulResults.push(result.value) + } + } + + if (bestError) { + throw bestError + } + return successfulResults +} diff --git a/packages/client/tests/functional/issues/14373-batch-tx-error/_matrix.ts b/packages/client/tests/functional/issues/14373-batch-tx-error/_matrix.ts new file mode 100644 index 000000000000..7a6b8ff0075c --- /dev/null +++ b/packages/client/tests/functional/issues/14373-batch-tx-error/_matrix.ts @@ -0,0 +1,24 @@ +import { defineMatrix } from '../../_utils/defineMatrix' + +export default defineMatrix(() => [ + [ + { + provider: 'sqlite', + }, + { + provider: 'postgresql', + }, + { + provider: 'mysql', + }, + { + provider: 'mongodb', + }, + { + provider: 'cockroachdb', + }, + { + provider: 'sqlserver', + }, + ], +]) diff --git a/packages/client/tests/functional/issues/14373-batch-tx-error/prisma/_schema.ts b/packages/client/tests/functional/issues/14373-batch-tx-error/prisma/_schema.ts new file mode 100644 index 000000000000..a75c03293f54 --- /dev/null +++ b/packages/client/tests/functional/issues/14373-batch-tx-error/prisma/_schema.ts @@ -0,0 +1,22 @@ +import { idForProvider } from '../../../_utils/idForProvider' +import testMatrix from '../_matrix' + +export default testMatrix.setupSchema(({ provider }) => { + return /* Prisma */ ` + generator client { + provider = "prisma-client-js" + } + + datasource db { + provider = "${provider}" + url = env("DATABASE_URI_${provider}") + } + + model User { + id ${idForProvider(provider)} + email String @unique + memo String? + createdAt DateTime @default(now()) + } + ` +}) diff --git a/packages/client/tests/functional/issues/14373-batch-tx-error/tests.ts b/packages/client/tests/functional/issues/14373-batch-tx-error/tests.ts new file mode 100644 index 000000000000..e42a3329420e --- /dev/null +++ b/packages/client/tests/functional/issues/14373-batch-tx-error/tests.ts @@ -0,0 +1,24 @@ +// @ts-ignore +import type { PrismaClient } from '@prisma/client' + +import testMatrix from './_matrix' + +declare let prisma: PrismaClient + +testMatrix.setupTestSuite(() => { + test('correctly reports location of a batch error', async () => { + const result = prisma.$transaction([ + prisma.user.findMany({}), + prisma.user.update({ + where: { + email: 'notthere@example.com', + }, + data: { + memo: 'id is 1', + }, + }), + ]) + + await expect(result).rejects.toThrowError('Invalid `prisma.user.update()` invocation') + }) +}) diff --git a/packages/engine-core/src/binary/BinaryEngine.ts b/packages/engine-core/src/binary/BinaryEngine.ts index d81b94c34be0..6b3c16d4644d 100644 --- a/packages/engine-core/src/binary/BinaryEngine.ts +++ b/packages/engine-core/src/binary/BinaryEngine.ts @@ -493,10 +493,9 @@ ${chalk.dim("In case we're mistaken, please report this to us 🙏.")}`) await this.startPromise if (!this.child && !this.engineEndpoint) { - throw new PrismaClientUnknownRequestError( - `Can't perform request, as the Engine has already been stopped`, - this.clientVersion!, - ) + throw new PrismaClientUnknownRequestError(`Can't perform request, as the Engine has already been stopped`, { + clientVersion: this.clientVersion!, + }) } } @@ -951,7 +950,7 @@ You very likely have the wrong "binaryTarget" defined in the schema.prisma file. throw prismaGraphQLToJSError(data.errors[0], this.clientVersion!) } // this case should not happen, as the query engine only returns one error - throw new PrismaClientUnknownRequestError(JSON.stringify(data.errors), this.clientVersion!) + throw new PrismaClientUnknownRequestError(JSON.stringify(data.errors), { clientVersion: this.clientVersion! }) } // Rust engine returns time in microseconds and we want it in milliseconds @@ -1095,10 +1094,9 @@ You very likely have the wrong "binaryTarget" defined in the schema.prisma file. } if (this.lastErrorLog && isRustErrorLog(this.lastErrorLog)) { - const err = new PrismaClientUnknownRequestError( - this.getErrorMessageWithLink(getMessage(this.lastErrorLog)), - this.clientVersion!, - ) + const err = new PrismaClientUnknownRequestError(this.getErrorMessageWithLink(getMessage(this.lastErrorLog)), { + clientVersion: this.clientVersion!, + }) if (this.lastErrorLog?.fields?.message === 'PANIC') { this.lastPanic = err @@ -1157,7 +1155,7 @@ and your request can't be processed. You probably have some open handle that prevents your process from exiting. It could be an open http server or stream that didn't close yet. We recommend using the \`wtfnode\` package to debug open handles.`, - this.clientVersion!, + { clientVersion: this.clientVersion! }, ) } @@ -1203,12 +1201,11 @@ Please look into the logs or turn on the env var DEBUG=* to debug the constantly */ transactionHttpErrorHandler(result: Result): never { const response = result.data as { [K: string]: unknown } - throw new PrismaClientKnownRequestError( - response.message as string, - response.error_code as string, - this.clientVersion as string, - response.meta, - ) + throw new PrismaClientKnownRequestError(response.message as string, { + code: response.error_code as string, + clientVersion: this.clientVersion as string, + meta: response.meta as Record, + }) } } diff --git a/packages/engine-core/src/common/errors/ErrorWithBatchIndex.ts b/packages/engine-core/src/common/errors/ErrorWithBatchIndex.ts new file mode 100644 index 000000000000..f81da08e288b --- /dev/null +++ b/packages/engine-core/src/common/errors/ErrorWithBatchIndex.ts @@ -0,0 +1,7 @@ +export interface ErrorWithBatchIndex { + batchRequestIdx?: number +} + +export function hasBatchIndex(value: object): value is Required { + return typeof value['batchRequestIdx'] === 'number' +} diff --git a/packages/engine-core/src/common/errors/PrismaClientKnownRequestError.ts b/packages/engine-core/src/common/errors/PrismaClientKnownRequestError.ts index acffc650d832..af86d13c60bb 100644 --- a/packages/engine-core/src/common/errors/PrismaClientKnownRequestError.ts +++ b/packages/engine-core/src/common/errors/PrismaClientKnownRequestError.ts @@ -1,14 +1,25 @@ -export class PrismaClientKnownRequestError extends Error { +import { ErrorWithBatchIndex } from './ErrorWithBatchIndex' + +type KnownErrorParams = { + code: string + clientVersion: string + meta?: Record + batchRequestIdx?: number +} + +export class PrismaClientKnownRequestError extends Error implements ErrorWithBatchIndex { code: string meta?: Record clientVersion: string + batchRequestIdx?: number - constructor(message: string, code: string, clientVersion: string, meta?: any) { + constructor(message: string, { code, clientVersion, meta, batchRequestIdx }: KnownErrorParams) { super(message) this.code = code this.clientVersion = clientVersion this.meta = meta + this.batchRequestIdx = batchRequestIdx } get [Symbol.toStringTag]() { return 'PrismaClientKnownRequestError' diff --git a/packages/engine-core/src/common/errors/PrismaClientUnknownRequestError.ts b/packages/engine-core/src/common/errors/PrismaClientUnknownRequestError.ts index b0fca3a2f92e..92df886bc023 100644 --- a/packages/engine-core/src/common/errors/PrismaClientUnknownRequestError.ts +++ b/packages/engine-core/src/common/errors/PrismaClientUnknownRequestError.ts @@ -1,10 +1,19 @@ -export class PrismaClientUnknownRequestError extends Error { +import { ErrorWithBatchIndex } from './ErrorWithBatchIndex' + +type UnknownErrorParams = { + clientVersion: string + batchRequestIdx?: number +} + +export class PrismaClientUnknownRequestError extends Error implements ErrorWithBatchIndex { clientVersion: string + batchRequestIdx?: number - constructor(message: string, clientVersion: string) { + constructor(message: string, { clientVersion, batchRequestIdx }: UnknownErrorParams) { super(message) this.clientVersion = clientVersion + this.batchRequestIdx = batchRequestIdx } get [Symbol.toStringTag]() { return 'PrismaClientUnknownRequestError' diff --git a/packages/engine-core/src/common/errors/types/RequestError.ts b/packages/engine-core/src/common/errors/types/RequestError.ts index 41f4cecabf93..3e4f687a4439 100644 --- a/packages/engine-core/src/common/errors/types/RequestError.ts +++ b/packages/engine-core/src/common/errors/types/RequestError.ts @@ -3,7 +3,8 @@ export interface RequestError { user_facing_error: { is_panic: boolean message: string - meta?: object + meta?: Record error_code?: string + batch_request_idx?: number } } diff --git a/packages/engine-core/src/common/errors/utils/prismaGraphQLToJSError.ts b/packages/engine-core/src/common/errors/utils/prismaGraphQLToJSError.ts index ffb0e4fc1075..e431b6ee507b 100644 --- a/packages/engine-core/src/common/errors/utils/prismaGraphQLToJSError.ts +++ b/packages/engine-core/src/common/errors/utils/prismaGraphQLToJSError.ts @@ -3,17 +3,20 @@ import { PrismaClientUnknownRequestError } from '../PrismaClientUnknownRequestEr import type { RequestError } from '../types/RequestError' export function prismaGraphQLToJSError( - error: RequestError, + { error, user_facing_error }: RequestError, clientVersion: string, ): PrismaClientKnownRequestError | PrismaClientUnknownRequestError { - if (error.user_facing_error.error_code) { - return new PrismaClientKnownRequestError( - error.user_facing_error.message, - error.user_facing_error.error_code, + if (user_facing_error.error_code) { + return new PrismaClientKnownRequestError(user_facing_error.message, { + code: user_facing_error.error_code, clientVersion, - error.user_facing_error.meta, - ) + meta: user_facing_error.meta, + batchRequestIdx: user_facing_error.batch_request_idx, + }) } - return new PrismaClientUnknownRequestError(error.error, clientVersion) + return new PrismaClientUnknownRequestError(error, { + clientVersion, + batchRequestIdx: user_facing_error.batch_request_idx, + }) } diff --git a/packages/engine-core/src/data-proxy/DataProxyEngine.ts b/packages/engine-core/src/data-proxy/DataProxyEngine.ts index 93f40f2d0ec8..dc65a2592fb9 100644 --- a/packages/engine-core/src/data-proxy/DataProxyEngine.ts +++ b/packages/engine-core/src/data-proxy/DataProxyEngine.ts @@ -208,7 +208,7 @@ export class DataProxyEngine extends Engine { if (data.errors.length === 1) { throw prismaGraphQLToJSError(data.errors[0], this.config.clientVersion!) } else { - throw new PrismaClientUnknownRequestError(data.errors, this.config.clientVersion!) + throw new PrismaClientUnknownRequestError(data.errors, { clientVersion: this.config.clientVersion! }) } } diff --git a/packages/engine-core/src/data-proxy/errors/utils/responseToError.ts b/packages/engine-core/src/data-proxy/errors/utils/responseToError.ts index 7123092a0c9f..ad3910a6f89a 100644 --- a/packages/engine-core/src/data-proxy/errors/utils/responseToError.ts +++ b/packages/engine-core/src/data-proxy/errors/utils/responseToError.ts @@ -95,7 +95,7 @@ export async function responseToError( const error = await getResponseErrorBody(response) if (error.type === 'QueryEngineError') { - throw new PrismaClientKnownRequestError(error.body.message, error.body.error_code, clientVersion) + throw new PrismaClientKnownRequestError(error.body.message, { code: error.body.error_code, clientVersion }) } if (error.type === 'DataProxyError') { diff --git a/packages/engine-core/src/index.ts b/packages/engine-core/src/index.ts index 2159e33929ae..6e24970d4cf9 100644 --- a/packages/engine-core/src/index.ts +++ b/packages/engine-core/src/index.ts @@ -4,6 +4,7 @@ export type { EngineEventType } from './common/Engine' export type { DatasourceOverwrite } from './common/Engine' export type { BatchTransactionOptions } from './common/Engine' export { Engine } from './common/Engine' +export { hasBatchIndex } from './common/errors/ErrorWithBatchIndex' export { PrismaClientInitializationError } from './common/errors/PrismaClientInitializationError' export { PrismaClientKnownRequestError } from './common/errors/PrismaClientKnownRequestError' export { PrismaClientRustPanicError } from './common/errors/PrismaClientRustPanicError' diff --git a/packages/engine-core/src/library/LibraryEngine.ts b/packages/engine-core/src/library/LibraryEngine.ts index 6f1ea2856a5f..2fd701de2b48 100644 --- a/packages/engine-core/src/library/LibraryEngine.ts +++ b/packages/engine-core/src/library/LibraryEngine.ts @@ -146,12 +146,11 @@ export class LibraryEngine extends Engine { const response = this.parseEngineResponse<{ [K: string]: unknown }>(result) if (response.error_code) { - throw new PrismaClientKnownRequestError( - response.message as string, - response.error_code as string, - this.config.clientVersion as string, - response.meta, - ) + throw new PrismaClientKnownRequestError(response.message as string, { + code: response.error_code as string, + clientVersion: this.config.clientVersion as string, + meta: response.meta as Record, + }) } return response as Tx.Info | undefined @@ -188,13 +187,17 @@ You may have to run ${chalk.greenBright('prisma generate')} for your changes to private parseEngineResponse(response?: string): T { if (!response) { - throw new PrismaClientUnknownRequestError(`Response from the Engine was empty`, this.config.clientVersion!) + throw new PrismaClientUnknownRequestError(`Response from the Engine was empty`, { + clientVersion: this.config.clientVersion!, + }) } try { const config = JSON.parse(response) return config as T } catch (err) { - throw new PrismaClientUnknownRequestError(`Unable to JSON.parse response from engine`, this.config.clientVersion!) + throw new PrismaClientUnknownRequestError(`Unable to JSON.parse response from engine`, { + clientVersion: this.config.clientVersion!, + }) } } @@ -460,7 +463,9 @@ You may have to run ${chalk.greenBright('prisma generate')} for your changes to throw this.buildQueryError(data.errors[0]) } // this case should not happen, as the query engine only returns one error - throw new PrismaClientUnknownRequestError(JSON.stringify(data.errors), this.config.clientVersion!) + throw new PrismaClientUnknownRequestError(JSON.stringify(data.errors), { + clientVersion: this.config.clientVersion!, + }) } else if (this.loggerRustPanic) { throw this.loggerRustPanic } @@ -477,7 +482,9 @@ You may have to run ${chalk.greenBright('prisma generate')} for your changes to if (typeof error === 'string') { throw e } else { - throw new PrismaClientUnknownRequestError(`${error.message}\n${error.backtrace}`, this.config.clientVersion!) + throw new PrismaClientUnknownRequestError(`${error.message}\n${error.backtrace}`, { + clientVersion: this.config.clientVersion!, + }) } } } @@ -505,7 +512,9 @@ You may have to run ${chalk.greenBright('prisma generate')} for your changes to throw this.buildQueryError(data.errors[0]) } // this case should not happen, as the query engine only returns one error - throw new PrismaClientUnknownRequestError(JSON.stringify(data.errors), this.config.clientVersion!) + throw new PrismaClientUnknownRequestError(JSON.stringify(data.errors), { + clientVersion: this.config.clientVersion!, + }) } const { batchResult, errors } = data