Skip to content

Commit c7c1221

Browse files
authored
[public-api] improve error handling (#18739)
* [dashboard] add exponential backoff for streaming emulation * [dashboard] add source map resolver * [server] fix actual bug * [public-api] compile to es6 to prevent duplicate @bufbuild modules in dashboard * [public-api] export ESM modules
1 parent 75753c1 commit c7c1221

File tree

5 files changed

+145
-20
lines changed

5 files changed

+145
-20
lines changed

components/dashboard/resolver.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
//@ts-check
7+
const path = require('path');
8+
const fetch = require('node-fetch').default;
9+
const { SourceMapConsumer } = require('source-map');
10+
11+
const sourceMapCache = {};
12+
13+
function extractJsUrlFromLine(line) {
14+
const match = line.match(/https?:\/\/[^\s]+\.js/);
15+
return match ? match[0] : null;
16+
}
17+
18+
async function fetchSourceMap(jsUrl) {
19+
// Use cached source map if available
20+
if (sourceMapCache[jsUrl]) {
21+
return sourceMapCache[jsUrl];
22+
}
23+
24+
const jsResponse = await fetch(jsUrl);
25+
const jsContent = await jsResponse.text();
26+
27+
// Extract source map URL from the JS file
28+
const mapUrlMatch = jsContent.match(/\/\/#\s*sourceMappingURL=(.+)/);
29+
if (!mapUrlMatch) {
30+
throw new Error('Source map URL not found');
31+
}
32+
33+
const mapUrl = new URL(mapUrlMatch[1], jsUrl).href; // Resolve relative URL
34+
const mapResponse = await fetch(mapUrl);
35+
const mapData = await mapResponse.json();
36+
37+
// Cache the fetched source map
38+
sourceMapCache[jsUrl] = mapData;
39+
40+
return mapData;
41+
}
42+
43+
const BASE_PATH = '/workspace/gitpod/components';
44+
45+
async function resolveLine(line) {
46+
const jsUrl = extractJsUrlFromLine(line);
47+
if (!jsUrl) return line;
48+
49+
const rawSourceMap = await fetchSourceMap(jsUrl);
50+
const matches = line.match(/at (?:([\S]+) )?\(?(https?:\/\/[^\s]+\.js):(\d+):(\d+)\)?/);
51+
52+
if (!matches) {
53+
return line;
54+
}
55+
56+
const functionName = matches[1] || '';
57+
const lineNum = Number(matches[3]);
58+
const colNum = Number(matches[4]);
59+
60+
const consumer = new SourceMapConsumer(rawSourceMap);
61+
const originalPosition = consumer.originalPositionFor({ line: lineNum, column: colNum });
62+
63+
if (originalPosition && originalPosition.source) {
64+
const fullPath = path.join(BASE_PATH, originalPosition.source);
65+
const originalFunctionName = originalPosition.name || functionName;
66+
return ` at ${originalFunctionName} (${fullPath}:${originalPosition.line}:${originalPosition.column})`;
67+
}
68+
69+
return line;
70+
}
71+
72+
73+
let obfuscatedTrace = '';
74+
75+
process.stdin.on('data', function(data) {
76+
obfuscatedTrace += data;
77+
});
78+
79+
process.stdin.on('end', async function() {
80+
const lines = obfuscatedTrace.split('\n');
81+
const resolvedLines = await Promise.all(lines.map(resolveLine));
82+
const resolvedTrace = resolvedLines.join('\n');
83+
console.log('\nResolved Stack Trace:\n');
84+
console.log(resolvedTrace);
85+
});
86+
87+
if (process.stdin.isTTY) {
88+
console.error("Please provide the obfuscated stack trace either as a multi-line input or from a file.");
89+
process.exit(1);
90+
}

components/dashboard/src/service/service.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ function testPublicAPI(service: any): void {
110110
});
111111
(async () => {
112112
const grpcType = "server-stream";
113+
const MAX_BACKOFF = 60000;
114+
const BASE_BACKOFF = 3000;
115+
let backoff = BASE_BACKOFF;
116+
113117
// emulates server side streaming with public API
114118
while (true) {
115119
const isTest = await getExperimentsClient().getValueAsync("public_api_dummy_reliability_test", false, {
@@ -121,15 +125,21 @@ function testPublicAPI(service: any): void {
121125
let previousCount = 0;
122126
for await (const reply of helloService.lotsOfReplies({ previousCount })) {
123127
previousCount = reply.count;
128+
backoff = BASE_BACKOFF;
124129
}
125130
} catch (e) {
126131
console.error(e, {
127132
userId: user?.id,
128133
grpcType,
129134
});
135+
backoff = Math.min(2 * backoff, MAX_BACKOFF);
130136
}
137+
} else {
138+
backoff = BASE_BACKOFF;
131139
}
132-
await new Promise((resolve) => setTimeout(resolve, 3000));
140+
const jitter = Math.random() * 0.3 * backoff;
141+
const delay = backoff + jitter;
142+
await new Promise((resolve) => setTimeout(resolve, delay));
133143
}
134144
})();
135145
}

components/public-api/typescript/package.json

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,31 @@
44
"license": "AGPL-3.0",
55
"main": "./lib/index.js",
66
"types": "./lib/index.d.ts",
7+
"module": "./lib/esm/index.js",
78
"files": [
89
"lib"
910
],
11+
"exports": {
12+
".": {
13+
"types": "./lib/index.d.ts",
14+
"import": "./lib/esm/index.js",
15+
"require": "./lib/index.js"
16+
},
17+
"./lib/*": {
18+
"types": "./lib/*.d.ts",
19+
"import": "./lib/esm/*.js",
20+
"require": "./lib/*.js"
21+
},
22+
"./lib/gitpod/experimental/v1": {
23+
"types": "./lib/gitpod/experimental/v1/index.d.ts",
24+
"import": "./lib/esm/gitpod/experimental/v1/index.js",
25+
"require": "./lib/gitpod/experimental/v1/index.js"
26+
}
27+
},
1028
"scripts": {
11-
"build": "mkdir -p lib; tsc",
29+
"build": "yarn run build:cjs && yarn run build:esm",
30+
"build:cjs": "tsc",
31+
"build:esm": "tsc --module es2015 --outDir ./lib/esm",
1232
"watch": "leeway exec --package .:lib --transitive-dependencies --filter-type yarn --components --parallel -- tsc -w --preserveWatchOutput",
1333
"test": "mocha './**/*.spec.ts' --exclude './node_modules/**' --exit",
1434
"test:brk": "yarn test --inspect-brk"
@@ -27,11 +47,11 @@
2747
"dependencies": {
2848
"@bufbuild/connect": "^0.13.0",
2949
"@bufbuild/protobuf": "^0.1.1",
30-
"@bufbuild/protoc-gen-connect-web": "^0.2.1",
31-
"@bufbuild/protoc-gen-es": "^0.1.1",
3250
"prom-client": "^14.2.0"
3351
},
3452
"devDependencies": {
53+
"@bufbuild/protoc-gen-connect-web": "^0.2.1",
54+
"@bufbuild/protoc-gen-es": "^0.1.1",
3555
"@testdeck/mocha": "0.1.2",
3656
"@types/chai": "^4.1.2",
3757
"@types/node": "^16.11.0",

components/public-api/typescript/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"rootDir": "src",
44
"experimentalDecorators": true,
55
"outDir": "lib",
6+
"declarationDir": "lib",
67
"lib": [
78
"es6",
89
"esnext.asynciterable",
@@ -14,7 +15,7 @@
1415
"emitDecoratorMetadata": true,
1516
"strictPropertyInitialization": false,
1617
"downlevelIteration": true,
17-
"module": "commonjs",
18+
"module": "CommonJS",
1819
"moduleResolution": "node",
1920
"target": "es6",
2021
"jsx": "react",

components/server/src/api/server.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import { APIStatsService } from "./stats";
2424
import { APITeamsService } from "./teams";
2525
import { APIUserService } from "./user";
2626
import { APIWorkspacesService } from "./workspaces";
27-
import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
2827

2928
function service<T extends ServiceType>(type: T, impl: ServiceImpl<T>): [T, ServiceImpl<T>] {
3029
return [type, impl];
@@ -55,7 +54,7 @@ export class API {
5554
this.register(app);
5655

5756
const server = app.listen(3001, () => {
58-
log.info(`Connect Public API server listening on port: ${(server.address() as AddressInfo).port}`);
57+
log.info(`public api: listening on port: ${(server.address() as AddressInfo).port}`);
5958
});
6059

6160
return server;
@@ -126,13 +125,22 @@ export class API {
126125

127126
grpcServerStarted.labels(grpc_service, grpc_method, grpc_type).inc();
128127
const stopTimer = grpcServerHandling.startTimer({ grpc_service, grpc_method, grpc_type });
129-
const deferred = new Deferred<ConnectError | undefined>();
130-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
131-
deferred.promise.then((err) => {
128+
const done = (err?: ConnectError) => {
132129
const grpc_code = err ? Code[err.code] : "OK";
133130
grpcServerHandled.labels(grpc_service, grpc_method, grpc_type, grpc_code).inc();
134131
stopTimer({ grpc_code });
135-
});
132+
};
133+
const handleError = (reason: unknown) => {
134+
let err = ConnectError.from(reason, Code.Internal);
135+
if (reason != err && err.code === Code.Internal) {
136+
console.error("public api: unexpected internal error", reason);
137+
// don't leak internal errors to a user
138+
// TODO(ak) instead surface request id
139+
err = ConnectError.from(`please check server logs`, Code.Internal);
140+
}
141+
done(err);
142+
throw err;
143+
};
136144

137145
const context = args[1] as HandlerContext;
138146
async function call<T>(): Promise<T> {
@@ -146,12 +154,10 @@ export class API {
146154
try {
147155
const promise = await call<Promise<any>>();
148156
const result = await promise;
149-
deferred.resolve(undefined);
157+
done();
150158
return result;
151159
} catch (e) {
152-
const err = ConnectError.from(e);
153-
deferred.resolve(e);
154-
throw err;
160+
handleError(e);
155161
}
156162
})();
157163
}
@@ -161,11 +167,9 @@ export class API {
161167
for await (const item of generator) {
162168
yield item;
163169
}
164-
deferred.resolve(undefined);
170+
done();
165171
} catch (e) {
166-
const err = ConnectError.from(e);
167-
deferred.resolve(err);
168-
throw err;
172+
handleError(e);
169173
}
170174
})();
171175
};
@@ -174,7 +178,7 @@ export class API {
174178
}
175179

176180
private async verify(context: HandlerContext) {
177-
const user = await this.sessionHandler.verify(context.requestHeader.get("cookie"));
181+
const user = await this.sessionHandler.verify(context.requestHeader.get("cookie") || "");
178182
if (!user) {
179183
throw new ConnectError("unauthenticated", Code.Unauthenticated);
180184
}

0 commit comments

Comments
 (0)