Skip to content

[dashboard/server] app error conversion based on error details #19103

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Alert from "./Alert";
import Modal from "./Modal";
import { Heading2 } from "./typography/headings";

export function UsageLimitReachedModal(p: { hints: any; onClose?: () => void }) {
export function UsageLimitReachedModal(p: { onClose?: () => void }) {
const currentOrg = useCurrentOrg();

const orgName = currentOrg.data?.name;
Expand Down
4 changes: 1 addition & 3 deletions components/dashboard/src/start/StartPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,7 @@ export function StartPage(props: StartPageProps) {
<ProgressBar phase={phase} error={!!error} />
)}
{error && error.code === ErrorCodes.NEEDS_VERIFICATION && <VerifyModal />}
{error && error.code === ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED && (
<UsageLimitReachedModal hints={error?.data} />
)}
{error && error.code === ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED && <UsageLimitReachedModal />}
{error && <StartError error={error} />}
{props.children}
<WarningView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,7 @@ const ErrorMessage: FunctionComponent<ErrorMessageProps> = ({ error, reset, setS
case ErrorCodes.INVALID_COST_CENTER:
return <RepositoryInputError title={`The organization '${error.data}' is not valid.`} />;
case ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED:
return <UsageLimitReachedModal onClose={reset} hints={error?.data} />;
return <UsageLimitReachedModal onClose={reset} />;
case ErrorCodes.NEEDS_VERIFICATION:
return <VerifyModal />;
default:
Expand Down
27 changes: 26 additions & 1 deletion components/gitpod-protocol/src/messaging/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@
*/

import { scrubber } from "../util/scrubbing";
import { PlainMessage } from "@bufbuild/protobuf";
import {
InvalidGitpodYMLError as InvalidGitpodYMLErrorData,
RepositoryNotFoundError as RepositoryNotFoundErrorData,
RepositoryUnauthorizedError as RepositoryUnauthorizedErrorData,
} from "@gitpod/public-api/lib/gitpod/v1/error_pb";

export class ApplicationError extends Error {
constructor(public readonly code: ErrorCode, message: string, public readonly data?: any) {
constructor(readonly code: ErrorCode, readonly message: string, readonly data?: any) {
super(message);
this.data = scrubber.scrub(this.data, true);
}
Expand All @@ -21,6 +27,25 @@ export class ApplicationError extends Error {
}
}

export class RepositoryNotFoundError extends ApplicationError {
constructor(readonly info: PlainMessage<RepositoryNotFoundErrorData>) {
// on gRPC we remap to PRECONDITION_FAILED, all error code for backwards compatibility with the dashboard
super(ErrorCodes.NOT_FOUND, "Repository not found.", info);
}
}
export class UnauthorizedRepositoryAccessError extends ApplicationError {
constructor(readonly info: PlainMessage<RepositoryUnauthorizedErrorData>) {
// on gRPC we remap to PRECONDITION_FAILED, all error code for backwards compatibility with the dashboard
super(ErrorCodes.NOT_AUTHENTICATED, "Repository unauthorized.", info);
}
}
export class InvalidGitpodYMLError extends ApplicationError {
constructor(readonly info: PlainMessage<InvalidGitpodYMLErrorData>) {
// on gRPC we remap to PRECONDITION_FAILED, all error code for backwards compatibility with the dashboard
super(ErrorCodes.INVALID_GITPOD_YML, "Invalid gitpod.yml: " + info.violations.join(","), info);
}
}

export namespace ApplicationError {
export function hasErrorCode(e: any): e is Error & { code: ErrorCode; data?: any } {
return ErrorCode.is(e["code"]);
Expand Down
350 changes: 349 additions & 1 deletion components/gitpod-protocol/src/public-api-converter.spec.ts

Large diffs are not rendered by default.

270 changes: 234 additions & 36 deletions components/gitpod-protocol/src/public-api-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,21 @@
* See License.AGPL.txt in the project root for license information.
*/

import { Timestamp } from "@bufbuild/protobuf";
import { Timestamp, toPlainMessage } from "@bufbuild/protobuf";
import { Code, ConnectError } from "@connectrpc/connect";
import {
FailedPreconditionDetails,
ImageBuildLogsNotYetAvailableError,
InvalidCostCenterError as InvalidCostCenterErrorData,
InvalidGitpodYMLError as InvalidGitpodYMLErrorData,
NeedsVerificationError,
PaymentSpendingLimitReachedError,
PermissionDeniedDetails,
RepositoryNotFoundError as RepositoryNotFoundErrorData,
RepositoryUnauthorizedError as RepositoryUnauthorizedErrorData,
TooManyRunningWorkspacesError,
UserBlockedError,
} from "@gitpod/public-api/lib/gitpod/v1/error_pb";
import {
AuthProvider,
AuthProviderDescription,
Expand Down Expand Up @@ -51,7 +64,13 @@ import {
PrebuildPhase,
PrebuildPhase_Phase,
} from "@gitpod/public-api/lib/gitpod/v1/prebuild_pb";
import { ApplicationError, ErrorCode, ErrorCodes } from "./messaging/error";
import {
ApplicationError,
ErrorCodes,
InvalidGitpodYMLError,
RepositoryNotFoundError,
UnauthorizedRepositoryAccessError,
} from "./messaging/error";
import {
AuthProviderEntry as AuthProviderProtocol,
AuthProviderInfo,
Expand Down Expand Up @@ -79,7 +98,6 @@ import {
Project,
Organization as ProtocolOrganization,
} from "./teams-projects-protocol";
import { TrustedValue } from "./util/scrubbing";
import {
ConfigurationIdeConfig,
PortProtocol,
Expand All @@ -92,9 +110,6 @@ import type { DeepPartial } from "./util/deep-partial";

export type PartialConfiguration = DeepPartial<Configuration> & Pick<Configuration, "id">;

const applicationErrorCode = "application-error-code";
const applicationErrorData = "application-error-data";

/**
* Converter between gRPC and JSON-RPC types.
*
Expand Down Expand Up @@ -199,57 +214,240 @@ export class PublicAPIConverter {
return reason;
}
if (reason instanceof ApplicationError) {
const metadata: HeadersInit = {};
metadata[applicationErrorCode] = String(reason.code);
if (reason.data) {
metadata[applicationErrorData] = JSON.stringify(reason.data);
if (reason.code === ErrorCodes.USER_BLOCKED) {
return new ConnectError(
reason.message,
Code.PermissionDenied,
undefined,
[
new PermissionDeniedDetails({
reason: {
case: "userBlocked",
value: new UserBlockedError(),
},
}),
],
reason,
);
}
if (reason.code === ErrorCodes.NEEDS_VERIFICATION) {
return new ConnectError(
reason.message,
Code.PermissionDenied,
undefined,
[
new PermissionDeniedDetails({
reason: {
case: "needsVerification",
value: new NeedsVerificationError(),
},
}),
],
reason,
);
}
if (reason instanceof InvalidGitpodYMLError) {
return new ConnectError(
reason.message,
Code.FailedPrecondition,
undefined,
[
new FailedPreconditionDetails({
reason: {
case: "invalidGitpodYml",
value: new InvalidGitpodYMLErrorData(reason.info),
},
}),
],
reason,
);
}
if (reason instanceof RepositoryNotFoundError) {
return new ConnectError(
reason.message,
Code.FailedPrecondition,
undefined,
[
new FailedPreconditionDetails({
reason: {
case: "repositoryNotFound",
value: new RepositoryNotFoundErrorData(reason.info),
},
}),
],
reason,
);
}
if (reason instanceof UnauthorizedRepositoryAccessError) {
return new ConnectError(
reason.message,
Code.FailedPrecondition,
undefined,
[
new FailedPreconditionDetails({
reason: {
case: "repositoryUnauthorized",
value: new RepositoryUnauthorizedErrorData(reason.info),
},
}),
],
reason,
);
}
if (reason.code === ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED) {
return new ConnectError(
reason.message,
Code.FailedPrecondition,
undefined,
[
new FailedPreconditionDetails({
reason: {
case: "paymentSpendingLimitReached",
value: new PaymentSpendingLimitReachedError(),
},
}),
],
reason,
);
}
if (reason.code === ErrorCodes.INVALID_COST_CENTER) {
return new ConnectError(
reason.message,
Code.FailedPrecondition,
undefined,
[
new FailedPreconditionDetails({
reason: {
case: "invalidCostCenter",
value: new InvalidCostCenterErrorData({
attributionId: reason.data.attributionId,
}),
},
}),
],
reason,
);
}
if (reason.code === ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE) {
return new ConnectError(
reason.message,
Code.FailedPrecondition,
undefined,
[
new FailedPreconditionDetails({
reason: {
case: "imageBuildLogsNotYetAvailable",
value: new ImageBuildLogsNotYetAvailableError(),
},
}),
],
reason,
);
}
if (reason.code === ErrorCodes.TOO_MANY_RUNNING_WORKSPACES) {
return new ConnectError(
reason.message,
Code.FailedPrecondition,
undefined,
[
new FailedPreconditionDetails({
reason: {
case: "tooManyRunningWorkspaces",
value: new TooManyRunningWorkspacesError(),
},
}),
],
reason,
);
}
if (reason.code === ErrorCodes.NOT_FOUND) {
return new ConnectError(reason.message, Code.NotFound, metadata, undefined, reason);
return new ConnectError(reason.message, Code.NotFound, undefined, undefined, reason);
}
if (reason.code === ErrorCodes.NOT_AUTHENTICATED) {
return new ConnectError(reason.message, Code.Unauthenticated, metadata, undefined, reason);
return new ConnectError(reason.message, Code.Unauthenticated, undefined, undefined, reason);
}
if (reason.code === ErrorCodes.PERMISSION_DENIED || reason.code === ErrorCodes.USER_BLOCKED) {
return new ConnectError(reason.message, Code.PermissionDenied, metadata, undefined, reason);
if (reason.code === ErrorCodes.PERMISSION_DENIED) {
return new ConnectError(reason.message, Code.PermissionDenied, undefined, undefined, reason);
}
if (reason.code === ErrorCodes.CONFLICT) {
return new ConnectError(reason.message, Code.AlreadyExists, metadata, undefined, reason);
return new ConnectError(reason.message, Code.AlreadyExists, undefined, undefined, reason);
}
if (reason.code === ErrorCodes.PRECONDITION_FAILED) {
return new ConnectError(reason.message, Code.FailedPrecondition, metadata, undefined, reason);
return new ConnectError(reason.message, Code.FailedPrecondition, undefined, undefined, reason);
}
if (reason.code === ErrorCodes.TOO_MANY_REQUESTS) {
return new ConnectError(reason.message, Code.ResourceExhausted, metadata, undefined, reason);
}
if (reason.code === ErrorCodes.INTERNAL_SERVER_ERROR) {
return new ConnectError(reason.message, Code.Internal, metadata, undefined, reason);
return new ConnectError(reason.message, Code.ResourceExhausted, undefined, undefined, reason);
}
if (reason.code === ErrorCodes.CANCELLED) {
return new ConnectError(reason.message, Code.DeadlineExceeded, metadata, undefined, reason);
return new ConnectError(reason.message, Code.Canceled, undefined, undefined, reason);
}
return new ConnectError(reason.message, Code.InvalidArgument, metadata, undefined, reason);
if (reason.code === ErrorCodes.INTERNAL_SERVER_ERROR) {
return new ConnectError(reason.message, Code.Internal, undefined, undefined, reason);
}
return new ConnectError(reason.message, Code.Unknown, undefined, undefined, reason);
}
return ConnectError.from(reason, Code.Internal);
}

fromError(reason: ConnectError): Error {
const codeMetadata = reason.metadata?.get(applicationErrorCode);
if (!codeMetadata) {
return reason;
fromError(reason: ConnectError): ApplicationError {
if (reason.code === Code.NotFound) {
return new ApplicationError(ErrorCodes.NOT_FOUND, reason.rawMessage);
}
const code = Number(codeMetadata) as ErrorCode;
const dataMetadata = reason.metadata?.get(applicationErrorData);
let data = undefined;
if (dataMetadata) {
try {
data = JSON.parse(dataMetadata);
} catch (e) {
console.error("failed to parse application error data", e);
if (reason.code === Code.Unauthenticated) {
return new ApplicationError(ErrorCodes.NOT_AUTHENTICATED, reason.rawMessage);
}
if (reason.code === Code.PermissionDenied) {
const details = reason.findDetails(PermissionDeniedDetails)[0];
switch (details?.reason?.case) {
case "userBlocked":
return new ApplicationError(ErrorCodes.USER_BLOCKED, reason.rawMessage);
case "needsVerification":
return new ApplicationError(ErrorCodes.NEEDS_VERIFICATION, reason.rawMessage);
}
return new ApplicationError(ErrorCodes.PERMISSION_DENIED, reason.rawMessage);
}
if (reason.code === Code.AlreadyExists) {
return new ApplicationError(ErrorCodes.CONFLICT, reason.rawMessage);
}
if (reason.code === Code.FailedPrecondition) {
const details = reason.findDetails(FailedPreconditionDetails)[0];
switch (details?.reason?.case) {
case "invalidGitpodYml":
const invalidGitpodYmlInfo = toPlainMessage(details.reason.value);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return new InvalidGitpodYMLError(invalidGitpodYmlInfo);
case "repositoryNotFound":
const repositoryNotFoundInfo = toPlainMessage(details.reason.value);
return new RepositoryNotFoundError(repositoryNotFoundInfo);
case "repositoryUnauthorized":
const repositoryUnauthorizedInfo = toPlainMessage(details.reason.value);
return new UnauthorizedRepositoryAccessError(repositoryUnauthorizedInfo);
case "paymentSpendingLimitReached":
return new ApplicationError(ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED, reason.rawMessage);
case "invalidCostCenter":
const invalidCostCenterInfo = toPlainMessage(details.reason.value);
return new ApplicationError(
ErrorCodes.INVALID_COST_CENTER,
reason.rawMessage,
invalidCostCenterInfo,
);
case "imageBuildLogsNotYetAvailable":
return new ApplicationError(ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE, reason.rawMessage);
case "tooManyRunningWorkspaces":
return new ApplicationError(ErrorCodes.TOO_MANY_RUNNING_WORKSPACES, reason.rawMessage);
}
return new ApplicationError(ErrorCodes.PRECONDITION_FAILED, reason.rawMessage);
}
if (reason.code === Code.ResourceExhausted) {
return new ApplicationError(ErrorCodes.TOO_MANY_REQUESTS, reason.rawMessage);
}
if (reason.code === Code.Canceled) {
return new ApplicationError(ErrorCodes.CANCELLED, reason.rawMessage);
}
if (reason.code === Code.Internal) {
return new ApplicationError(ErrorCodes.INTERNAL_SERVER_ERROR, reason.rawMessage);
}
// data is trusted here, since it was scrubbed before on the server
return new ApplicationError(code, reason.message, new TrustedValue(data));
return new ApplicationError(ErrorCodes.INTERNAL_SERVER_ERROR, reason.rawMessage);
}

toWorkspaceEnvironmentVariables(context: WorkspaceContext): WorkspaceEnvironmentVariable[] {
Expand Down
Loading