Skip to content

Commit

Permalink
[dashboard/server] app error conversion based on error details (#19103)
Browse files Browse the repository at this point in the history
  • Loading branch information
akosyakov authored Nov 22, 2023
1 parent b929a24 commit 70517fb
Show file tree
Hide file tree
Showing 12 changed files with 660 additions and 191 deletions.
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

0 comments on commit 70517fb

Please sign in to comment.