feat(server): introduce user friendly server errors (#7111)

This commit is contained in:
liuyi
2024-06-17 11:30:58 +08:00
committed by GitHub
parent 5307a55f8a
commit 54fc1197ad
65 changed files with 3170 additions and 924 deletions
@@ -1,11 +1,7 @@
import {
BadRequestException,
Controller,
Get,
HttpException,
InternalServerErrorException,
Logger,
NotFoundException,
Param,
Query,
Req,
@@ -23,14 +19,21 @@ import {
merge,
mergeMap,
Observable,
of,
switchMap,
toArray,
} from 'rxjs';
import { Public } from '../../core/auth';
import { CurrentUser } from '../../core/auth/current-user';
import { Config } from '../../fundamentals';
import {
BlobNotFound,
Config,
CopilotFailedToGenerateText,
CopilotSessionNotFound,
mapSseError,
NoCopilotProviderAvailable,
UnsplashIsNotConfigured,
} from '../../fundamentals';
import { CopilotProviderService } from './providers';
import { ChatSession, ChatSessionService } from './session';
import { CopilotStorage } from './storage';
@@ -40,7 +43,7 @@ import { CopilotWorkflowService } from './workflow';
export interface ChatEvent {
type: 'attachment' | 'message' | 'error';
id?: string;
data: string;
data: string | object;
}
type CheckResult = {
@@ -68,7 +71,7 @@ export class CopilotController {
await this.chatSession.checkQuota(userId);
const session = await this.chatSession.get(sessionId);
if (!session || session.config.userId !== userId) {
throw new BadRequestException('Session not found');
throw new CopilotSessionNotFound();
}
const ret: CheckResult = { model: session.model };
@@ -104,7 +107,7 @@ export class CopilotController {
);
}
if (!provider) {
throw new InternalServerErrorException('No provider available');
throw new NoCopilotProviderAvailable();
}
return provider;
@@ -116,7 +119,7 @@ export class CopilotController {
): Promise<ChatSession> {
const session = await this.chatSession.get(sessionId);
if (!session) {
throw new BadRequestException('Session not found');
throw new CopilotSessionNotFound();
}
if (messageId) {
@@ -148,20 +151,6 @@ export class CopilotController {
return num;
}
private handleError(err: any) {
if (err instanceof Error) {
const ret = {
message: err.message,
status: (err as any).status,
};
if (err instanceof HttpException) {
ret.status = err.getStatus();
}
return ret;
}
return err;
}
@Get('/chat/:sessionId')
async chat(
@CurrentUser() user: CurrentUser,
@@ -200,9 +189,7 @@ export class CopilotController {
return content;
} catch (e: any) {
throw new InternalServerErrorException(
e.message || "Couldn't generate text"
);
throw new CopilotFailedToGenerateText(e.message);
}
}
@@ -253,18 +240,10 @@ export class CopilotController {
)
)
),
catchError(err =>
of({
type: 'error' as const,
data: this.handleError(err),
})
)
catchError(mapSseError)
);
} catch (err) {
return of({
type: 'error' as const,
data: this.handleError(err),
});
return mapSseError(err);
}
}
@@ -318,18 +297,10 @@ export class CopilotController {
)
)
),
catchError(err =>
of({
type: 'error' as const,
data: this.handleError(err),
})
)
catchError(mapSseError)
);
} catch (err) {
return of({
type: 'error' as const,
data: this.handleError(err),
});
return mapSseError(err);
}
}
@@ -356,7 +327,7 @@ export class CopilotController {
model
);
if (!provider) {
throw new InternalServerErrorException('No provider available');
throw new NoCopilotProviderAvailable();
}
const session = await this.appendSessionMessage(sessionId, messageId);
@@ -402,18 +373,10 @@ export class CopilotController {
)
)
),
catchError(err =>
of({
type: 'error' as const,
data: this.handleError(err),
})
)
catchError(mapSseError)
);
} catch (err) {
return of({
type: 'error' as const,
data: this.handleError(err),
});
return mapSseError(err);
}
}
@@ -425,7 +388,7 @@ export class CopilotController {
) {
const { unsplashKey } = this.config.plugins.copilot || {};
if (!unsplashKey) {
throw new InternalServerErrorException('Unsplash key is not configured');
throw new UnsplashIsNotConfigured();
}
const query = new URLSearchParams(params);
@@ -458,9 +421,10 @@ export class CopilotController {
const { body, metadata } = await this.storage.get(userId, workspaceId, key);
if (!body) {
throw new NotFoundException(
`Blob not found in ${userId}'s workspace ${workspaceId}: ${key}`
);
throw new BlobNotFound({
workspaceId,
blobId: key,
});
}
// metadata should always exists if body is not null
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
Args,
Field,
@@ -23,6 +23,7 @@ import { Admin } from '../../core/common';
import { UserType } from '../../core/user';
import { PermissionService } from '../../core/workspaces/permission';
import {
CopilotFailedToCreateMessage,
FileUpload,
MutexService,
Throttle,
@@ -201,8 +202,6 @@ export class CopilotType {
@Throttle()
@Resolver(() => CopilotType)
export class CopilotResolver {
private readonly logger = new Logger(CopilotResolver.name);
constructor(
private readonly permissions: PermissionService,
private readonly mutex: MutexService,
@@ -385,8 +384,7 @@ export class CopilotResolver {
try {
return await this.chatSession.createMessage(options);
} catch (e: any) {
this.logger.error(`Failed to create chat message: ${e.message}`);
throw new Error('Failed to create chat message');
throw new CopilotFailedToCreateMessage(e.message);
}
}
}
@@ -5,7 +5,14 @@ import { AiPromptRole, PrismaClient } from '@prisma/client';
import { FeatureManagementService } from '../../core/features';
import { QuotaService } from '../../core/quota';
import { PaymentRequiredException } from '../../fundamentals';
import {
CopilotActionTaken,
CopilotMessageNotFound,
CopilotPromptNotFound,
CopilotQuotaExceeded,
CopilotSessionDeleted,
CopilotSessionNotFound,
} from '../../fundamentals';
import { ChatMessageCache } from './message';
import { PromptService } from './prompt';
import {
@@ -58,7 +65,7 @@ export class ChatSession implements AsyncDisposable {
this.state.messages.length > 0 &&
message.role === 'user'
) {
throw new Error('Action has been taken, no more messages allowed');
throw new CopilotActionTaken();
}
this.state.messages.push(message);
this.stashMessageCount += 1;
@@ -74,7 +81,7 @@ export class ChatSession implements AsyncDisposable {
async getMessageById(messageId: string) {
const message = await this.messageCache.get(messageId);
if (!message || message.sessionId !== this.state.sessionId) {
throw new Error(`Message not found: ${messageId}`);
throw new CopilotMessageNotFound();
}
return message;
}
@@ -82,7 +89,7 @@ export class ChatSession implements AsyncDisposable {
async pushByMessageId(messageId: string) {
const message = await this.messageCache.get(messageId);
if (!message || message.sessionId !== this.state.sessionId) {
throw new Error(`Message not found: ${messageId}`);
throw new CopilotMessageNotFound();
}
this.push({
@@ -196,7 +203,7 @@ export class ChatSessionService {
},
select: { id: true, deletedAt: true },
})) || {};
if (deletedAt) throw new Error(`Session is deleted: ${id}`);
if (deletedAt) throw new CopilotSessionDeleted();
if (id) sessionId = id;
}
@@ -274,7 +281,8 @@ export class ChatSessionService {
.then(async session => {
if (!session) return;
const prompt = await this.prompt.get(session.promptName);
if (!prompt) throw new Error(`Prompt not found: ${session.promptName}`);
if (!prompt)
throw new CopilotPromptNotFound({ name: session.promptName });
const messages = ChatMessageSchema.array().safeParse(session.messages);
@@ -300,7 +308,7 @@ export class ChatSessionService {
})
.then(session => session?.id);
if (!id) {
throw new Error(`Session not found: ${sessionId}`);
throw new CopilotSessionNotFound();
}
const ids = await tx.aiSessionMessage
.findMany({
@@ -412,7 +420,7 @@ export class ChatSessionService {
if (ret.success) {
const prompt = await this.prompt.get(promptName);
if (!prompt) {
throw new Error(`Prompt not found: ${promptName}`);
throw new CopilotPromptNotFound({ name: promptName });
}
// render system prompt
@@ -471,9 +479,7 @@ export class ChatSessionService {
async checkQuota(userId: string) {
const { limit, used } = await this.getQuota(userId);
if (limit && Number.isFinite(limit) && used >= limit) {
throw new PaymentRequiredException(
`You have reached the limit of actions in this workspace, please upgrade your plan.`
);
throw new CopilotQuotaExceeded();
}
}
@@ -482,7 +488,7 @@ export class ChatSessionService {
const prompt = await this.prompt.get(options.promptName);
if (!prompt) {
this.logger.error(`Prompt not found: ${options.promptName}`);
throw new Error('Prompt not found');
throw new CopilotPromptNotFound({ name: options.promptName });
}
return await this.setSession({
...options,
@@ -1,10 +1,11 @@
import { createHash } from 'node:crypto';
import { Injectable, PayloadTooLargeException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { QuotaManagementService } from '../../core/quota';
import {
type BlobInputType,
BlobQuotaExceeded,
Config,
type FileUpload,
type StorageProvider,
@@ -54,9 +55,7 @@ export class CopilotStorage {
const checkExceeded = await this.quota.getQuotaCalculator(userId);
if (checkExceeded(0)) {
throw new PayloadTooLargeException(
'Storage or blob size limit exceeded.'
);
throw new BlobQuotaExceeded();
}
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = blob.createReadStream();
@@ -67,9 +66,7 @@ export class CopilotStorage {
// check size after receive each chunk to avoid unnecessary memory usage
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
if (checkExceeded(bufferSize)) {
reject(
new PayloadTooLargeException('Storage or blob size limit exceeded.')
);
reject(new BlobQuotaExceeded());
}
});
stream.on('error', reject);
@@ -77,7 +74,7 @@ export class CopilotStorage {
const buffer = Buffer.concat(chunks);
if (checkExceeded(buffer.length)) {
reject(new PayloadTooLargeException('Storage limit exceeded.'));
reject(new BlobQuotaExceeded());
} else {
resolve(buffer);
}