Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/30609-external-import-outage-503.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/rest': patch
---

fix(rest): the external-datasource routes relay a declared service outage as `503 SERVICE_UNAVAILABLE`

Clause-②: no
Metadata-protocol: none

`POST /api/v1/datasources/:name/external/tables/:remote/import` answered a `tenancy` service outage as `400 EXTERNAL_IMPORT_ERROR`, telling the caller the request was malformed. The import reads the effective tenancy posture to decide where the imported object lands, and a registered `tenancy` service that cannot answer throws the `503` / `SERVICE_UNAVAILABLE` envelope there. The route now relays that envelope, as the datasource admin routes and the MCP `import_object` tool already do, so the caller is told to retry. Nothing was saved before and nothing is saved now. The other routes under `/datasources/:name/external/*` share the same catch and relay a declared outage the same way; every other refusal keeps its `400` and code.
16 changes: 16 additions & 0 deletions packages/rest/src/external-datasource-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,19 @@ export function registerExternalDatasourceRoutes(
* which told the caller to re-declare a datasource that does not exist. Both
* fields are required, so an unrelated error carrying a stray `status`
* cannot re-route itself.
*
* [#30609] A second relay of the same kind, the one `badRequest` makes
* first: an error carrying the `503` / `SERVICE_UNAVAILABLE` envelope has
* classified itself as a DEPENDENCY OUTAGE, and this family's `400` told the
* caller the request was malformed, the opposite of "retry". The import's
* landing reads the effective tenancy posture, and a registered `tenancy`
* service that cannot answer throws `AuthzStoreUnavailableError` there
* (`classifyAdmissionTenancyPosture`); nothing is saved, so only the status
* was wrong. Read off the envelope, as `badRequest` and the MCP
* `import_object` tool read it, rather than off the authorization brand, so
* every route here relays any service's declared outage the same way: the
* `EXTERNAL_DATASOURCE_ERROR` routes share the catch and the same truth. Both
* fields are required, for the reason above.
*/
const answerThrown = (
res: any,
Expand All @@ -552,6 +565,9 @@ export function registerExternalDatasourceRoutes(
return sendError(res, nested.status, nested.code, nested.message, nested.extra);
}
const envelope = err as { code?: unknown; status?: unknown } | null | undefined;
if (envelope?.status === 503 && envelope?.code === 'SERVICE_UNAVAILABLE') {
return sendError(res, 503, 'SERVICE_UNAVAILABLE', (err as Error).message);
}
if (envelope?.status === 404 && envelope?.code === 'RESOURCE_NOT_FOUND') {
return sendError(res, 404, 'RESOURCE_NOT_FOUND', (err as Error).message);
}
Expand Down
68 changes: 64 additions & 4 deletions packages/rest/src/external-datasource-unknown-name.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,22 @@
* Driven through the real `HonoHttpServer`.
*/

import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { classifyAdmissionTenancyPosture } from '@objectstack/core';
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
import { ExternalDatasourceService } from '@objectstack/service-datasource';
import { ExternalDatasourceService, type ExternalDatasourceServiceConfig } from '@objectstack/service-datasource';
import { registerExternalDatasourceRoutes } from './external-datasource-routes.js';

/** `warehouse` exists and is managed: it declares no `schemaMode`. */
/**
* `warehouse` exists and is managed: it declares no `schemaMode`. `lake` is a
* federation source.
*/
const DEFINITIONS: Record<string, { name: string; schemaMode?: 'managed' | 'external' }> = {
warehouse: { name: 'warehouse' },
lake: { name: 'lake', schemaMode: 'external' },
};

function mount() {
function mount(overrides: Partial<ExternalDatasourceServiceConfig> = {}) {
const service = new ExternalDatasourceService({
introspect: async () => {
throw new Error('introspect must not be reached');
Expand All @@ -44,6 +49,7 @@ function mount() {
persistObject: async () => {
throw new Error('persistObject must not be reached');
},
...overrides,
});
const server = new HonoHttpServer(0);
registerExternalDatasourceRoutes(
Expand Down Expand Up @@ -161,3 +167,57 @@ describe('POST …/external/validate on a name that resolves no saved datasource
});
});
});

/**
* [#30609] The import on a `tenancy` service outage. The import's landing
* reads the effective tenancy posture (`classifyAdmissionTenancyPosture`, the
* read `service-datasource`'s plugin makes), and a registered `tenancy`
* service that cannot answer throws `AuthzStoreUnavailableError` there:
* declared `503` / `SERVICE_UNAVAILABLE`. The catch answered it `400
* EXTERNAL_IMPORT_ERROR`, a malformed request, where the admin routes and the
* MCP `import_object` tool relay the outage. The ruling these pins hold: the
* outage is relayed as `503 SERVICE_UNAVAILABLE` and nothing is saved; a plain
* refusal from the same landing keeps the door's `400` and code.
*/
describe('POST …/external/tables/:remote/import on a tenancy service outage', () => {
/** A `tenancy` service registered through a factory that fails. */
const outageLanding = async () => {
await classifyAdmissionTenancyPosture(() => Promise.reject(new Error('tenancy factory failed')));
throw new Error('the posture read must not answer');
};

it('answers 503 SERVICE_UNAVAILABLE and saves nothing', async () => {
const persistObject = vi.fn(async () => undefined);
const call = mount({ resolveImportLanding: outageLanding, persistObject });

const answer = await call('POST', '/lake/external/tables/orders/import');

expect({
status: answer.status,
success: answer.body.success,
code: answer.body.error?.code,
}).toEqual({ status: 503, success: false, code: 'SERVICE_UNAVAILABLE' });
expect(answer.body.error.message).toContain('(failed read: `tenancy`)');
expect(answer.body).not.toHaveProperty('data');
expect(persistObject).not.toHaveBeenCalled();
});

it('still refuses a plain landing refusal 400 EXTERNAL_IMPORT_ERROR', async () => {
const persistObject = vi.fn(async () => undefined);
const call = mount({
resolveImportLanding: async () => {
throw new Error('no package to be saved into');
},
persistObject,
});

const answer = await call('POST', '/lake/external/tables/orders/import');

expect({ status: answer.status, code: answer.body.error?.code, message: answer.body.error?.message }).toEqual({
status: 400,
code: 'EXTERNAL_IMPORT_ERROR',
message: 'no package to be saved into',
});
expect(persistObject).not.toHaveBeenCalled();
});
});
Loading