Skip to content

Commit 366105c

Browse files
os-zhuangclaude
andauthored
fix(service-datasource,rest): the last three uncovered datasource routes answer their registered refusal code (#4264) (#4313)
#4249 (#4263) gave the rest surface's two introspection routes a failure contract; three sibling routes still had no catch around their service call, so a throw was swallowed by the adapter and surfaced as the pre-#3675 non-envelope 500 { error: 'No response from handler' } — no success flag, no error.message, the real cause lost. Each now answers 400 in the declared envelope, under the code registered (ADR-0112) for the service the route dispatches to: - GET /api/v1/datasources → DATASOURCE_ADMIN_ERROR, matching its eight siblings in admin-routes.ts - POST /datasources/:name/external/refresh-catalog and POST /datasources/:name/external/validate → EXTERNAL_DATASOURCE_ERROR, the code #4249 gave the two introspection routes above them The issue left INTERNAL_ERROR open as an alternative; the per-service codes win on consistency — every other catch in both modules, including pure reads, already answers 400 with the service-attributed code, and refreshCatalog's dominant throw class is the one #4249 already adjudicated as a 400 refusal on listRemoteTables. A 500 would fork the failure contract within a module — the drift #4249 removed. No new codes (both registered by #4263). The envelope-conformance suites and the REFUSALS pin table gain one row per route. Closes #4264 Claude-Session: https://claude.ai/code/session_01VDgGWS97x6vuikjmgMswtk Co-authored-by: Claude <noreply@anthropic.com>
1 parent b09d8d9 commit 366105c

6 files changed

Lines changed: 109 additions & 12 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
"@objectstack/rest": patch
4+
---
5+
6+
fix(service-datasource,rest): the last three uncovered datasource routes answer their registered refusal code (#4264)
7+
8+
#4249 (fixed in #4263) gave the rest surface's two introspection routes a
9+
failure contract; this closes the same gap on the three sibling routes it left
10+
uncovered. Each had no `catch` around its service call, so a service throw was
11+
swallowed by the adapter and surfaced as the pre-#3675 non-envelope
12+
`500 { error: 'No response from handler' }` — no `success` flag, no
13+
`error.message`, no code to switch on, real cause lost.
14+
15+
Wire-visible changes — each route now answers `400` in the declared envelope,
16+
under the refusal code registered (ADR-0112) for the service it dispatches to,
17+
with the service's own message at `error.message`:
18+
19+
- `GET /api/v1/datasources` (`listDatasources` throw) →
20+
`400 DATASOURCE_ADMIN_ERROR` — matching its eight siblings in
21+
`service-datasource/admin-routes.ts`, which already answer their catches this
22+
way.
23+
- `POST /api/v1/datasources/:name/external/refresh-catalog` (`refreshCatalog`
24+
throw) and `POST /api/v1/datasources/:name/external/validate` (`validateAll`
25+
throw) → `400 EXTERNAL_DATASOURCE_ERROR` — the same code #4249 gave the two
26+
introspection routes one block above them.
27+
28+
The issue left the code choice open (`INTERNAL_ERROR` was the alternative);
29+
the registered per-service codes win on consistency: every other catch in both
30+
modules — including pure reads — already answers 400 with the service-attributed
31+
code, and `refreshCatalog`'s dominant throw class (unknown datasource,
32+
unreachable remote, no such schema) is the one #4249 already adjudicated as a
33+
400 refusal on `listRemoteTables`. A 500 here would fork the failure contract
34+
within a module — the drift #4249 removed.
35+
36+
No new codes: both were registered in the error-code ledger by #4263. The
37+
envelope-conformance suites and the `REFUSALS` pin table gain one row per
38+
route.

packages/rest/src/external-datasource-envelope.conformance.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,29 @@ describe('external-datasource envelope (#3843) — error bodies', () => {
227227
{ params: { name: 'ext', remote: 'customers' } },
228228
),
229229
},
230+
{
231+
// #4264: the two rows below are the routes #4249 left uncovered — no
232+
// `catch` at all, so these throws surfaced as the adapter's non-envelope
233+
// `500 { error: 'No response from handler' }`, the real cause swallowed.
234+
name: 'a catalog refresh the service refuses',
235+
status: 400,
236+
code: 'EXTERNAL_DATASOURCE_ERROR',
237+
run: () => drive(
238+
mount({ refreshCatalog: async () => { throw new Error('unknown datasource "ext"'); } }),
239+
'POST',
240+
`${EXT}/refresh-catalog`,
241+
),
242+
},
243+
{
244+
name: 'a validation sweep the service refuses',
245+
status: 400,
246+
code: 'EXTERNAL_DATASOURCE_ERROR',
247+
run: () => drive(
248+
mount({ validateAll: async () => { throw new Error('metadata store offline'); } }),
249+
'POST',
250+
`${EXT}/validate`,
251+
),
252+
},
230253
];
231254

232255
for (const c of CASES) {

packages/rest/src/external-datasource-routes.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ import { sendOk, sendError } from '@objectstack/types';
4343
* non-envelope `500 { error: 'No response from handler' }` — while the SAME two
4444
* service operations, reached through `service-datasource/admin-routes.ts`,
4545
* answered 400. One operation, one failure contract now, on both paths.
46+
* #4264 closed the same gap on the two routes #4249 left uncovered —
47+
* `refreshCatalog` / `validateAll` — whose throws (unknown datasource,
48+
* unreachable remote, metadata-store failure) took the same uncaught path to
49+
* that adapter 500. Every service call this module makes is now wrapped.
4650
*
4751
* Note `POST /validate` keeps its `ok` — unlike the `{ ok: true, key }` #3689
4852
* retired from storage, that one was a private second word for `success`, while
@@ -69,8 +73,13 @@ export function registerExternalDatasourceRoutes(
6973
const unavailable = (res: any) =>
7074
sendError(res, 503, 'SERVICE_UNAVAILABLE', 'The external-datasource service is not available.');
7175

72-
/** An introspection refusal — same code as the admin-routes path (#4249). */
73-
const introspectionRefused = (res: any, err: unknown) =>
76+
/**
77+
* A refusal from the external-datasource service — same code as the
78+
* admin-routes path (#4249). Named after the service, not one operation:
79+
* since #4264 every route here except the import (which has its own
80+
* registered code) answers its catch with this.
81+
*/
82+
const refused = (res: any, err: unknown) =>
7483
sendError(res, 400, 'EXTERNAL_DATASOURCE_ERROR', err instanceof Error ? err.message : String(err));
7584

7685
// List remote tables (optionally filtered by ?schema=).
@@ -82,7 +91,7 @@ export function registerExternalDatasourceRoutes(
8291
const tables = await svc.listRemoteTables(req.params.name, { schema });
8392
sendOk(res, { tables });
8493
} catch (err) {
85-
introspectionRefused(res, err);
94+
refused(res, err);
8695
}
8796
});
8897

@@ -98,7 +107,7 @@ export function registerExternalDatasourceRoutes(
98107
);
99108
sendOk(res, { draft });
100109
} catch (err) {
101-
introspectionRefused(res, err);
110+
refused(res, err);
102111
}
103112
});
104113

@@ -130,16 +139,24 @@ export function registerExternalDatasourceRoutes(
130139
server.post(`${ext}/refresh-catalog`, async (req: any, res: any) => {
131140
const svc = externalService();
132141
if (!svc?.refreshCatalog) return unavailable(res);
133-
const catalog = await svc.refreshCatalog(req.params.name);
134-
sendOk(res, { catalog });
142+
try {
143+
const catalog = await svc.refreshCatalog(req.params.name);
144+
sendOk(res, { catalog });
145+
} catch (err) {
146+
refused(res, err);
147+
}
135148
});
136149

137150
// Validate the federated objects on this datasource.
138151
server.post(`${ext}/validate`, async (req: any, res: any) => {
139152
const svc = externalService();
140153
if (!svc?.validateAll) return unavailable(res);
141-
const report = await svc.validateAll();
142-
const results = (report.results ?? []).filter((r: any) => r.datasource === req.params.name);
143-
sendOk(res, { ok: results.every((r: any) => r.ok), results });
154+
try {
155+
const report = await svc.validateAll();
156+
const results = (report.results ?? []).filter((r: any) => r.datasource === req.params.name);
157+
sendOk(res, { ok: results.every((r: any) => r.ok), results });
158+
} catch (err) {
159+
refused(res, err);
160+
}
144161
});
145162
}

packages/services/service-datasource/src/__tests__/admin-routes.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,9 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
260260
svc: Record<string, unknown>;
261261
run: (app: any) => Promise<Response>;
262262
}> = [
263+
// The list row is #4264: the one route that had no refusal path at all —
264+
// its throw surfaced as the adapter's non-envelope 500, not any code here.
265+
{ route: 'GET /datasources', service: 'datasource-admin', code: 'DATASOURCE_ADMIN_ERROR', svc: { listDatasources: async () => { throw new Error('backing store offline'); } }, run: (a) => a.fetch(json('/api/v1/datasources')) },
263266
{ route: 'GET /datasources/:name', service: 'datasource-admin', code: 'DATASOURCE_ADMIN_ERROR', svc: { getDatasource: async () => { throw new Error('backing store offline'); } }, run: (a) => a.fetch(json('/api/v1/datasources/pg')) },
264267
{ route: 'POST /datasources/test', service: 'datasource-admin', code: 'DATASOURCE_ADMIN_ERROR', svc: { testConnection: async () => { throw new Error('backing store offline'); } }, run: (a) => a.fetch(json('/api/v1/datasources/test', { method: 'POST', body: '{}' })) },
265268
{ route: 'POST /datasources', service: 'datasource-admin', code: 'DATASOURCE_ADMIN_ERROR', svc: { createDatasource: async () => { throw new Error('backing store offline'); } }, run: (a) => a.fetch(json('/api/v1/datasources', { method: 'POST', body: '{}' })) },

packages/services/service-datasource/src/__tests__/envelope.conformance.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,15 @@ describe('datasource-admin envelope (#3843) — error bodies', () => {
187187
code: 'DATASOURCE_ADMIN_ERROR',
188188
run: () => drive(mount({ createDatasource: async () => { throw new Error('duplicate name'); } }), '/api/v1/datasources', { method: 'POST', body: JSON.stringify({ name: 'pg' }) }),
189189
},
190+
{
191+
// #4264: the one route in the module that still had no `catch`, so this
192+
// throw surfaced as the adapter's non-envelope
193+
// `500 { error: 'No response from handler' }` instead of the 400 below.
194+
name: 'a datasource listing failure',
195+
status: 400,
196+
code: 'DATASOURCE_ADMIN_ERROR',
197+
run: () => drive(mount({ listDatasources: async () => { throw new Error('backing store offline'); } }), '/api/v1/datasources'),
198+
},
190199
{
191200
// On an external-datasource route, so the refusal carries THAT service's
192201
// registered code (#4249) — even though this one is raised by the route

packages/services/service-datasource/src/admin-routes.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,12 +170,19 @@ export function registerDatasourceAdminRoutes(
170170
return { draft, secret: normalised };
171171
};
172172

173-
// List all datasources with provenance + health.
173+
// List all datasources with provenance + health. The catch was missing
174+
// until #4264 — the one route in this module without one, so a backing-store
175+
// failure surfaced as the adapter's non-envelope 500 instead of the 400 its
176+
// eight siblings answer.
174177
server.get(root, async (_req: any, res: any) => {
175178
const svc = resolve(res, 'datasource-admin', 'listDatasources');
176179
if (!svc) return;
177-
const datasources = await svc.listDatasources();
178-
sendOk(res, { datasources });
180+
try {
181+
const datasources = await svc.listDatasources();
182+
sendOk(res, { datasources });
183+
} catch (err) {
184+
badRequest(res, 'datasource-admin', err);
185+
}
179186
});
180187

181188
// Catalog of connection drivers + their JSON-Schema config (drives the

0 commit comments

Comments
 (0)