Skip to content

Commit e72bee4

Browse files
authored
feat: Improve error handling from 3P requests on interceptor (#238)
* refactor: replace undefined return with ExecutionResult in execute() Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * test: update execute() tests for ExecutionResult and add AppKitError subclass coverage Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * chore: docs update * chore: preserve upstream HTTP status codes in execute() for non-AppKitError errors * docs: regenerate ExecutionResult API docs with error handling details Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * refactor: use HTTP status code descriptions in files plugin (#258) * refactor: use HTTP status code descriptions instead of hardcoded error strings Replace hardcoded error messages like "List failed" with standard HTTP status descriptions from node:http STATUS_CODES for consistent error responses. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * chore: dry status error responses in files plugin Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * fix: standardize stream error to use _sendStatusError helper Addresses Copilot review comment about mixed error formats in the read/download stream handler. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> --------- Signed-off-by: Atila Fassina <atila@fassina.eu> * fix: correct test assertions for 404/409 status text in files plugin The _sendStatusError() helper returns STATUS_CODES[status] (e.g., "Not Found" for 404, "Conflict" for 409), but tests incorrectly expected "Internal Server Error" for all error status codes. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> --------- Signed-off-by: Atila Fassina <atila@fassina.eu>
1 parent 6a00264 commit e72bee4

File tree

14 files changed

+425
-100
lines changed

14 files changed

+425
-100
lines changed

docs/docs/api/appkit/Class.Plugin.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,14 +313,16 @@ BasePlugin.clientConfig
313313
protected execute<T>(
314314
fn: (signal?: AbortSignal) => Promise<T>,
315315
options: PluginExecutionSettings,
316-
userKey?: string): Promise<T | undefined>;
316+
userKey?: string): Promise<ExecutionResult<T>>;
317317
```
318318

319319
Execute a function with the plugin's interceptor chain.
320320

321-
All errors are caught and `undefined` is returned (production-safe).
322-
Route handlers should check for `undefined` and respond with an
323-
appropriate error status.
321+
Returns an [ExecutionResult](TypeAlias.ExecutionResult.md) discriminated union:
322+
- `{ ok: true, data: T }` on success
323+
- `{ ok: false, status: number, message: string }` on failure
324+
325+
Errors are never thrown — the method is production-safe.
324326

325327
#### Type Parameters
326328

@@ -338,7 +340,7 @@ appropriate error status.
338340

339341
#### Returns
340342

341-
`Promise`\<`T` \| `undefined`\>
343+
`Promise`\<[`ExecutionResult`](TypeAlias.ExecutionResult.md)\<`T`\>\>
342344

343345
***
344346

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Type Alias: ExecutionResult\<T\>
2+
3+
```ts
4+
type ExecutionResult<T> =
5+
| {
6+
data: T;
7+
ok: true;
8+
}
9+
| {
10+
message: string;
11+
ok: false;
12+
status: number;
13+
};
14+
```
15+
16+
Discriminated union for plugin execution results.
17+
18+
Replaces the previous `T | undefined` return type on `execute()`.
19+
20+
On failure, the HTTP status code is preserved from:
21+
- `AppKitError` subclasses (via `statusCode`)
22+
- Any `Error` with a numeric `statusCode` property (e.g. `ApiError`)
23+
- All other errors default to status 500
24+
25+
In production, error messages from non-AppKitError sources are handled as:
26+
- 4xx errors: original message is preserved (client-facing by design)
27+
- 5xx errors: replaced with "Server error" to prevent information leakage
28+
29+
## Type Parameters
30+
31+
| Type Parameter |
32+
| ------ |
33+
| `T` |

docs/docs/api/appkit/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ plugin architecture, and React integration.
5454
| Type Alias | Description |
5555
| ------ | ------ |
5656
| [ConfigSchema](TypeAlias.ConfigSchema.md) | Configuration schema definition for plugin config. Re-exported from the standard JSON Schema Draft 7 types. |
57+
| [ExecutionResult](TypeAlias.ExecutionResult.md) | Discriminated union for plugin execution results. |
5758
| [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration |
5859
| [PluginData](TypeAlias.PluginData.md) | Tuple of plugin class, config, and name. Created by `toPlugin()` and passed to `createApp()`. |
5960
| [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. |

docs/docs/api/appkit/typedoc-sidebar.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ const typedocSidebar: SidebarsConfig = {
183183
id: "api/appkit/TypeAlias.ConfigSchema",
184184
label: "ConfigSchema"
185185
},
186+
{
187+
type: "doc",
188+
id: "api/appkit/TypeAlias.ExecutionResult",
189+
label: "ExecutionResult"
190+
},
186191
{
187192
type: "doc",
188193
id: "api/appkit/TypeAlias.IAppRouter",

packages/appkit/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ export {
4747
ValidationError,
4848
} from "./errors";
4949
// Plugin authoring
50-
export { Plugin, type ToPlugin, toPlugin } from "./plugin";
50+
export {
51+
type ExecutionResult,
52+
Plugin,
53+
type ToPlugin,
54+
toPlugin,
55+
} from "./plugin";
5156
export { analytics, files, genie, lakebase, server, serving } from "./plugins";
5257
export type {
5358
EndpointConfig,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Discriminated union for plugin execution results.
3+
*
4+
* Replaces the previous `T | undefined` return type on `execute()`.
5+
*
6+
* On failure, the HTTP status code is preserved from:
7+
* - `AppKitError` subclasses (via `statusCode`)
8+
* - Any `Error` with a numeric `statusCode` property (e.g. `ApiError`)
9+
* - All other errors default to status 500
10+
*
11+
* In production, error messages from non-AppKitError sources are handled as:
12+
* - 4xx errors: original message is preserved (client-facing by design)
13+
* - 5xx errors: replaced with "Server error" to prevent information leakage
14+
*/
15+
export type ExecutionResult<T> =
16+
| { ok: true; data: T }
17+
| { ok: false; status: number; message: string };
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export type { ToPlugin } from "shared";
2+
export type { ExecutionResult } from "./execution-result";
23
export { Plugin } from "./plugin";
34
export { toPlugin } from "./to-plugin";

packages/appkit/src/plugin/plugin.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
ServiceContext,
2020
type UserContext,
2121
} from "../context";
22-
import { AuthenticationError } from "../errors";
22+
import { AppKitError, AuthenticationError } from "../errors";
2323
import { createLogger } from "../logging/logger";
2424
import { StreamManager } from "../stream";
2525
import {
@@ -29,6 +29,7 @@ import {
2929
} from "../telemetry";
3030
import { deepMerge } from "../utils";
3131
import { DevFileReader } from "./dev-reader";
32+
import type { ExecutionResult } from "./execution-result";
3233
import { CacheInterceptor } from "./interceptors/cache";
3334
import { RetryInterceptor } from "./interceptors/retry";
3435
import { TelemetryInterceptor } from "./interceptors/telemetry";
@@ -40,6 +41,20 @@ import type {
4041

4142
const logger = createLogger("plugin");
4243

44+
/**
45+
* Narrow an unknown thrown value to an Error that carries a numeric
46+
* `statusCode` property (e.g. `ApiError` from `@databricks/sdk-experimental`).
47+
*/
48+
function hasHttpStatusCode(
49+
error: unknown,
50+
): error is Error & { statusCode: number } {
51+
return (
52+
error instanceof Error &&
53+
"statusCode" in error &&
54+
typeof (error as Record<string, unknown>).statusCode === "number"
55+
);
56+
}
57+
4358
/**
4459
* Methods that should not be proxied by asUser().
4560
* These are lifecycle/internal methods that don't make sense
@@ -435,15 +450,17 @@ export abstract class Plugin<
435450
/**
436451
* Execute a function with the plugin's interceptor chain.
437452
*
438-
* All errors are caught and `undefined` is returned (production-safe).
439-
* Route handlers should check for `undefined` and respond with an
440-
* appropriate error status.
453+
* Returns an {@link ExecutionResult} discriminated union:
454+
* - `{ ok: true, data: T }` on success
455+
* - `{ ok: false, status: number, message: string }` on failure
456+
*
457+
* Errors are never thrown — the method is production-safe.
441458
*/
442459
protected async execute<T>(
443460
fn: (signal?: AbortSignal) => Promise<T>,
444461
options: PluginExecutionSettings,
445462
userKey?: string,
446-
): Promise<T | undefined> {
463+
): Promise<ExecutionResult<T>> {
447464
const executeConfig = this._buildExecutionConfig(options);
448465

449466
const interceptors = this._buildInterceptors(executeConfig);
@@ -457,11 +474,40 @@ export abstract class Plugin<
457474
};
458475

459476
try {
460-
return await this._executeWithInterceptors(fn, interceptors, context);
477+
const data = await this._executeWithInterceptors(
478+
fn,
479+
interceptors,
480+
context,
481+
);
482+
return { ok: true, data };
461483
} catch (error) {
462-
// production-safe: swallow all errors, don't crash the app
463484
logger.error("Plugin execution failed", { error, plugin: this.name });
464-
return undefined;
485+
486+
if (error instanceof AppKitError) {
487+
return {
488+
ok: false,
489+
status: error.statusCode,
490+
message: error.message,
491+
};
492+
}
493+
494+
if (hasHttpStatusCode(error)) {
495+
const isDev = process.env.NODE_ENV !== "production";
496+
const isClientError = error.statusCode >= 400 && error.statusCode < 500;
497+
return {
498+
ok: false,
499+
status: error.statusCode,
500+
message: isDev || isClientError ? error.message : "Server error",
501+
};
502+
}
503+
504+
const isDev = process.env.NODE_ENV !== "production";
505+
return {
506+
ok: false,
507+
status: 500,
508+
message:
509+
isDev && error instanceof Error ? error.message : "Server error",
510+
};
465511
}
466512
}
467513

0 commit comments

Comments
 (0)