Skip to content

Commit 70517fb

Browse files
authored
[dashboard/server] app error conversion based on error details (#19103)
1 parent b929a24 commit 70517fb

File tree

12 files changed

+660
-191
lines changed

12 files changed

+660
-191
lines changed

components/dashboard/src/components/UsageLimitReachedModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Alert from "./Alert";
1010
import Modal from "./Modal";
1111
import { Heading2 } from "./typography/headings";
1212

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

1616
const orgName = currentOrg.data?.name;

components/dashboard/src/start/StartPage.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,7 @@ export function StartPage(props: StartPageProps) {
111111
<ProgressBar phase={phase} error={!!error} />
112112
)}
113113
{error && error.code === ErrorCodes.NEEDS_VERIFICATION && <VerifyModal />}
114-
{error && error.code === ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED && (
115-
<UsageLimitReachedModal hints={error?.data} />
116-
)}
114+
{error && error.code === ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED && <UsageLimitReachedModal />}
117115
{error && <StartError error={error} />}
118116
{props.children}
119117
<WarningView

components/dashboard/src/workspaces/CreateWorkspacePage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ const ErrorMessage: FunctionComponent<ErrorMessageProps> = ({ error, reset, setS
522522
case ErrorCodes.INVALID_COST_CENTER:
523523
return <RepositoryInputError title={`The organization '${error.data}' is not valid.`} />;
524524
case ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED:
525-
return <UsageLimitReachedModal onClose={reset} hints={error?.data} />;
525+
return <UsageLimitReachedModal onClose={reset} />;
526526
case ErrorCodes.NEEDS_VERIFICATION:
527527
return <VerifyModal />;
528528
default:

components/gitpod-protocol/src/messaging/error.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@
55
*/
66

77
import { scrubber } from "../util/scrubbing";
8+
import { PlainMessage } from "@bufbuild/protobuf";
9+
import {
10+
InvalidGitpodYMLError as InvalidGitpodYMLErrorData,
11+
RepositoryNotFoundError as RepositoryNotFoundErrorData,
12+
RepositoryUnauthorizedError as RepositoryUnauthorizedErrorData,
13+
} from "@gitpod/public-api/lib/gitpod/v1/error_pb";
814

915
export class ApplicationError extends Error {
10-
constructor(public readonly code: ErrorCode, message: string, public readonly data?: any) {
16+
constructor(readonly code: ErrorCode, readonly message: string, readonly data?: any) {
1117
super(message);
1218
this.data = scrubber.scrub(this.data, true);
1319
}
@@ -21,6 +27,25 @@ export class ApplicationError extends Error {
2127
}
2228
}
2329

30+
export class RepositoryNotFoundError extends ApplicationError {
31+
constructor(readonly info: PlainMessage<RepositoryNotFoundErrorData>) {
32+
// on gRPC we remap to PRECONDITION_FAILED, all error code for backwards compatibility with the dashboard
33+
super(ErrorCodes.NOT_FOUND, "Repository not found.", info);
34+
}
35+
}
36+
export class UnauthorizedRepositoryAccessError extends ApplicationError {
37+
constructor(readonly info: PlainMessage<RepositoryUnauthorizedErrorData>) {
38+
// on gRPC we remap to PRECONDITION_FAILED, all error code for backwards compatibility with the dashboard
39+
super(ErrorCodes.NOT_AUTHENTICATED, "Repository unauthorized.", info);
40+
}
41+
}
42+
export class InvalidGitpodYMLError extends ApplicationError {
43+
constructor(readonly info: PlainMessage<InvalidGitpodYMLErrorData>) {
44+
// on gRPC we remap to PRECONDITION_FAILED, all error code for backwards compatibility with the dashboard
45+
super(ErrorCodes.INVALID_GITPOD_YML, "Invalid gitpod.yml: " + info.violations.join(","), info);
46+
}
47+
}
48+
2449
export namespace ApplicationError {
2550
export function hasErrorCode(e: any): e is Error & { code: ErrorCode; data?: any } {
2651
return ErrorCode.is(e["code"]);

components/gitpod-protocol/src/public-api-converter.spec.ts

Lines changed: 349 additions & 1 deletion
Large diffs are not rendered by default.

components/gitpod-protocol/src/public-api-converter.ts

Lines changed: 234 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,21 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { Timestamp } from "@bufbuild/protobuf";
7+
import { Timestamp, toPlainMessage } from "@bufbuild/protobuf";
88
import { Code, ConnectError } from "@connectrpc/connect";
9+
import {
10+
FailedPreconditionDetails,
11+
ImageBuildLogsNotYetAvailableError,
12+
InvalidCostCenterError as InvalidCostCenterErrorData,
13+
InvalidGitpodYMLError as InvalidGitpodYMLErrorData,
14+
NeedsVerificationError,
15+
PaymentSpendingLimitReachedError,
16+
PermissionDeniedDetails,
17+
RepositoryNotFoundError as RepositoryNotFoundErrorData,
18+
RepositoryUnauthorizedError as RepositoryUnauthorizedErrorData,
19+
TooManyRunningWorkspacesError,
20+
UserBlockedError,
21+
} from "@gitpod/public-api/lib/gitpod/v1/error_pb";
922
import {
1023
AuthProvider,
1124
AuthProviderDescription,
@@ -51,7 +64,13 @@ import {
5164
PrebuildPhase,
5265
PrebuildPhase_Phase,
5366
} from "@gitpod/public-api/lib/gitpod/v1/prebuild_pb";
54-
import { ApplicationError, ErrorCode, ErrorCodes } from "./messaging/error";
67+
import {
68+
ApplicationError,
69+
ErrorCodes,
70+
InvalidGitpodYMLError,
71+
RepositoryNotFoundError,
72+
UnauthorizedRepositoryAccessError,
73+
} from "./messaging/error";
5574
import {
5675
AuthProviderEntry as AuthProviderProtocol,
5776
AuthProviderInfo,
@@ -79,7 +98,6 @@ import {
7998
Project,
8099
Organization as ProtocolOrganization,
81100
} from "./teams-projects-protocol";
82-
import { TrustedValue } from "./util/scrubbing";
83101
import {
84102
ConfigurationIdeConfig,
85103
PortProtocol,
@@ -92,9 +110,6 @@ import type { DeepPartial } from "./util/deep-partial";
92110

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

95-
const applicationErrorCode = "application-error-code";
96-
const applicationErrorData = "application-error-data";
97-
98113
/**
99114
* Converter between gRPC and JSON-RPC types.
100115
*
@@ -199,57 +214,240 @@ export class PublicAPIConverter {
199214
return reason;
200215
}
201216
if (reason instanceof ApplicationError) {
202-
const metadata: HeadersInit = {};
203-
metadata[applicationErrorCode] = String(reason.code);
204-
if (reason.data) {
205-
metadata[applicationErrorData] = JSON.stringify(reason.data);
217+
if (reason.code === ErrorCodes.USER_BLOCKED) {
218+
return new ConnectError(
219+
reason.message,
220+
Code.PermissionDenied,
221+
undefined,
222+
[
223+
new PermissionDeniedDetails({
224+
reason: {
225+
case: "userBlocked",
226+
value: new UserBlockedError(),
227+
},
228+
}),
229+
],
230+
reason,
231+
);
232+
}
233+
if (reason.code === ErrorCodes.NEEDS_VERIFICATION) {
234+
return new ConnectError(
235+
reason.message,
236+
Code.PermissionDenied,
237+
undefined,
238+
[
239+
new PermissionDeniedDetails({
240+
reason: {
241+
case: "needsVerification",
242+
value: new NeedsVerificationError(),
243+
},
244+
}),
245+
],
246+
reason,
247+
);
248+
}
249+
if (reason instanceof InvalidGitpodYMLError) {
250+
return new ConnectError(
251+
reason.message,
252+
Code.FailedPrecondition,
253+
undefined,
254+
[
255+
new FailedPreconditionDetails({
256+
reason: {
257+
case: "invalidGitpodYml",
258+
value: new InvalidGitpodYMLErrorData(reason.info),
259+
},
260+
}),
261+
],
262+
reason,
263+
);
264+
}
265+
if (reason instanceof RepositoryNotFoundError) {
266+
return new ConnectError(
267+
reason.message,
268+
Code.FailedPrecondition,
269+
undefined,
270+
[
271+
new FailedPreconditionDetails({
272+
reason: {
273+
case: "repositoryNotFound",
274+
value: new RepositoryNotFoundErrorData(reason.info),
275+
},
276+
}),
277+
],
278+
reason,
279+
);
280+
}
281+
if (reason instanceof UnauthorizedRepositoryAccessError) {
282+
return new ConnectError(
283+
reason.message,
284+
Code.FailedPrecondition,
285+
undefined,
286+
[
287+
new FailedPreconditionDetails({
288+
reason: {
289+
case: "repositoryUnauthorized",
290+
value: new RepositoryUnauthorizedErrorData(reason.info),
291+
},
292+
}),
293+
],
294+
reason,
295+
);
296+
}
297+
if (reason.code === ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED) {
298+
return new ConnectError(
299+
reason.message,
300+
Code.FailedPrecondition,
301+
undefined,
302+
[
303+
new FailedPreconditionDetails({
304+
reason: {
305+
case: "paymentSpendingLimitReached",
306+
value: new PaymentSpendingLimitReachedError(),
307+
},
308+
}),
309+
],
310+
reason,
311+
);
312+
}
313+
if (reason.code === ErrorCodes.INVALID_COST_CENTER) {
314+
return new ConnectError(
315+
reason.message,
316+
Code.FailedPrecondition,
317+
undefined,
318+
[
319+
new FailedPreconditionDetails({
320+
reason: {
321+
case: "invalidCostCenter",
322+
value: new InvalidCostCenterErrorData({
323+
attributionId: reason.data.attributionId,
324+
}),
325+
},
326+
}),
327+
],
328+
reason,
329+
);
330+
}
331+
if (reason.code === ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE) {
332+
return new ConnectError(
333+
reason.message,
334+
Code.FailedPrecondition,
335+
undefined,
336+
[
337+
new FailedPreconditionDetails({
338+
reason: {
339+
case: "imageBuildLogsNotYetAvailable",
340+
value: new ImageBuildLogsNotYetAvailableError(),
341+
},
342+
}),
343+
],
344+
reason,
345+
);
346+
}
347+
if (reason.code === ErrorCodes.TOO_MANY_RUNNING_WORKSPACES) {
348+
return new ConnectError(
349+
reason.message,
350+
Code.FailedPrecondition,
351+
undefined,
352+
[
353+
new FailedPreconditionDetails({
354+
reason: {
355+
case: "tooManyRunningWorkspaces",
356+
value: new TooManyRunningWorkspacesError(),
357+
},
358+
}),
359+
],
360+
reason,
361+
);
206362
}
207363
if (reason.code === ErrorCodes.NOT_FOUND) {
208-
return new ConnectError(reason.message, Code.NotFound, metadata, undefined, reason);
364+
return new ConnectError(reason.message, Code.NotFound, undefined, undefined, reason);
209365
}
210366
if (reason.code === ErrorCodes.NOT_AUTHENTICATED) {
211-
return new ConnectError(reason.message, Code.Unauthenticated, metadata, undefined, reason);
367+
return new ConnectError(reason.message, Code.Unauthenticated, undefined, undefined, reason);
212368
}
213-
if (reason.code === ErrorCodes.PERMISSION_DENIED || reason.code === ErrorCodes.USER_BLOCKED) {
214-
return new ConnectError(reason.message, Code.PermissionDenied, metadata, undefined, reason);
369+
if (reason.code === ErrorCodes.PERMISSION_DENIED) {
370+
return new ConnectError(reason.message, Code.PermissionDenied, undefined, undefined, reason);
215371
}
216372
if (reason.code === ErrorCodes.CONFLICT) {
217-
return new ConnectError(reason.message, Code.AlreadyExists, metadata, undefined, reason);
373+
return new ConnectError(reason.message, Code.AlreadyExists, undefined, undefined, reason);
218374
}
219375
if (reason.code === ErrorCodes.PRECONDITION_FAILED) {
220-
return new ConnectError(reason.message, Code.FailedPrecondition, metadata, undefined, reason);
376+
return new ConnectError(reason.message, Code.FailedPrecondition, undefined, undefined, reason);
221377
}
222378
if (reason.code === ErrorCodes.TOO_MANY_REQUESTS) {
223-
return new ConnectError(reason.message, Code.ResourceExhausted, metadata, undefined, reason);
224-
}
225-
if (reason.code === ErrorCodes.INTERNAL_SERVER_ERROR) {
226-
return new ConnectError(reason.message, Code.Internal, metadata, undefined, reason);
379+
return new ConnectError(reason.message, Code.ResourceExhausted, undefined, undefined, reason);
227380
}
228381
if (reason.code === ErrorCodes.CANCELLED) {
229-
return new ConnectError(reason.message, Code.DeadlineExceeded, metadata, undefined, reason);
382+
return new ConnectError(reason.message, Code.Canceled, undefined, undefined, reason);
230383
}
231-
return new ConnectError(reason.message, Code.InvalidArgument, metadata, undefined, reason);
384+
if (reason.code === ErrorCodes.INTERNAL_SERVER_ERROR) {
385+
return new ConnectError(reason.message, Code.Internal, undefined, undefined, reason);
386+
}
387+
return new ConnectError(reason.message, Code.Unknown, undefined, undefined, reason);
232388
}
233389
return ConnectError.from(reason, Code.Internal);
234390
}
235391

236-
fromError(reason: ConnectError): Error {
237-
const codeMetadata = reason.metadata?.get(applicationErrorCode);
238-
if (!codeMetadata) {
239-
return reason;
392+
fromError(reason: ConnectError): ApplicationError {
393+
if (reason.code === Code.NotFound) {
394+
return new ApplicationError(ErrorCodes.NOT_FOUND, reason.rawMessage);
240395
}
241-
const code = Number(codeMetadata) as ErrorCode;
242-
const dataMetadata = reason.metadata?.get(applicationErrorData);
243-
let data = undefined;
244-
if (dataMetadata) {
245-
try {
246-
data = JSON.parse(dataMetadata);
247-
} catch (e) {
248-
console.error("failed to parse application error data", e);
396+
if (reason.code === Code.Unauthenticated) {
397+
return new ApplicationError(ErrorCodes.NOT_AUTHENTICATED, reason.rawMessage);
398+
}
399+
if (reason.code === Code.PermissionDenied) {
400+
const details = reason.findDetails(PermissionDeniedDetails)[0];
401+
switch (details?.reason?.case) {
402+
case "userBlocked":
403+
return new ApplicationError(ErrorCodes.USER_BLOCKED, reason.rawMessage);
404+
case "needsVerification":
405+
return new ApplicationError(ErrorCodes.NEEDS_VERIFICATION, reason.rawMessage);
406+
}
407+
return new ApplicationError(ErrorCodes.PERMISSION_DENIED, reason.rawMessage);
408+
}
409+
if (reason.code === Code.AlreadyExists) {
410+
return new ApplicationError(ErrorCodes.CONFLICT, reason.rawMessage);
411+
}
412+
if (reason.code === Code.FailedPrecondition) {
413+
const details = reason.findDetails(FailedPreconditionDetails)[0];
414+
switch (details?.reason?.case) {
415+
case "invalidGitpodYml":
416+
const invalidGitpodYmlInfo = toPlainMessage(details.reason.value);
417+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
418+
return new InvalidGitpodYMLError(invalidGitpodYmlInfo);
419+
case "repositoryNotFound":
420+
const repositoryNotFoundInfo = toPlainMessage(details.reason.value);
421+
return new RepositoryNotFoundError(repositoryNotFoundInfo);
422+
case "repositoryUnauthorized":
423+
const repositoryUnauthorizedInfo = toPlainMessage(details.reason.value);
424+
return new UnauthorizedRepositoryAccessError(repositoryUnauthorizedInfo);
425+
case "paymentSpendingLimitReached":
426+
return new ApplicationError(ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED, reason.rawMessage);
427+
case "invalidCostCenter":
428+
const invalidCostCenterInfo = toPlainMessage(details.reason.value);
429+
return new ApplicationError(
430+
ErrorCodes.INVALID_COST_CENTER,
431+
reason.rawMessage,
432+
invalidCostCenterInfo,
433+
);
434+
case "imageBuildLogsNotYetAvailable":
435+
return new ApplicationError(ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE, reason.rawMessage);
436+
case "tooManyRunningWorkspaces":
437+
return new ApplicationError(ErrorCodes.TOO_MANY_RUNNING_WORKSPACES, reason.rawMessage);
249438
}
439+
return new ApplicationError(ErrorCodes.PRECONDITION_FAILED, reason.rawMessage);
440+
}
441+
if (reason.code === Code.ResourceExhausted) {
442+
return new ApplicationError(ErrorCodes.TOO_MANY_REQUESTS, reason.rawMessage);
443+
}
444+
if (reason.code === Code.Canceled) {
445+
return new ApplicationError(ErrorCodes.CANCELLED, reason.rawMessage);
446+
}
447+
if (reason.code === Code.Internal) {
448+
return new ApplicationError(ErrorCodes.INTERNAL_SERVER_ERROR, reason.rawMessage);
250449
}
251-
// data is trusted here, since it was scrubbed before on the server
252-
return new ApplicationError(code, reason.message, new TrustedValue(data));
450+
return new ApplicationError(ErrorCodes.INTERNAL_SERVER_ERROR, reason.rawMessage);
253451
}
254452

255453
toWorkspaceEnvironmentVariables(context: WorkspaceContext): WorkspaceEnvironmentVariable[] {

0 commit comments

Comments
 (0)