Skip to content

Commit 41e703b

Browse files
authored
feat(connector-openapi): degrade + retry on an unreachable remote spec URL (#3049 follow-up) (#3179)
The openapi provider's remote spec-URL fetch now classifies faults like connector-mcp's connect path: a network error or transient HTTP status (408/429/5xx) throws ConnectorUpstreamUnavailableError so the materializer degrades + retries the instance, while a wrong URL (non-retryable 4xx) or unparseable document stays a fatal config fault. Inline/file-path specs unaffected; no service-automation change (the reconcile already routes the marker generically). +14 provider-layer tests; ADR-0097 scope-boundary list trued up.
1 parent 5f5762d commit 41e703b

4 files changed

Lines changed: 164 additions & 9 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/connector-openapi': minor
3+
---
4+
5+
feat(connector-openapi): degrade + retry on an unreachable remote spec URL (#3049 follow-up)
6+
7+
The `openapi` provider fetches `providerConfig.spec` when it is an http(s) URL.
8+
That fetch previously threw plain on any failure, so a momentarily-unreachable
9+
spec endpoint aborted the whole app boot. It now classifies the fault the same
10+
way `connector-mcp` classifies its connect path (ADR-0097):
11+
12+
- **Network error** (DNS / connection refused / timeout) or a **transient HTTP
13+
status** (`408` / `429` / `5xx`, mirroring the `retryableStatusCodes`
14+
convention) throws `ConnectorUpstreamUnavailableError` — the materializer
15+
degrades the instance (`state: 'degraded'` on `GET /connectors`, dispatch
16+
fails clearly) and retries with backoff plus on every `metadata:reloaded`.
17+
- A **wrong URL** (non-retryable `4xx`) or an **unparseable document** stays a
18+
plain, fatal configuration fault.
19+
20+
Inline and file-path (`#3016`) specs do no boot I/O and are unaffected.

docs/adr/0097-declarative-connector-instances.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,13 @@ dispatch was rejected: MCP actions ARE the server's `tools/list`, so a
167167
connector materialized without connecting would register a zero-action def —
168168
exactly the plausible-but-dead shape this ADR exists to kill.
169169

170+
The `openapi` provider applies the same split to its **remote-URL spec fetch**:
171+
an unreachable endpoint (network error) or a transient HTTP status
172+
(`408` / `429` / `5xx`, mirroring the `retryableStatusCodes` convention) is
173+
upstream-unavailable — degrade + retry — while a wrong URL (non-retryable
174+
`4xx`) or an unparseable document stays a fatal configuration fault. Inline and
175+
file-path (`#3016`) specs do no boot I/O and are unaffected.
176+
170177
### Declarative stdio policy: default-deny (follow-up, landed — #3055)
171178

172179
A declarative `provider: 'mcp'` entry may name a **stdio transport**, and
@@ -196,5 +203,4 @@ any default preset (#3056).
196203

197204
- **`providerConfig.spec` (openapi)** accepts an inline document, an http(s) URL, **or a package-relative file path** (#3016 follow-up). The connector still owns no filesystem access: the automation service injects a `loadPackageFile` capability into `ConnectorProviderContext` that resolves the ref against the declaring stack/package root (`packageRoot`, CLI default: the `objectstack.config.ts` directory) and **confines reads to that root** — absolute and `..`-escaping paths are rejected. Read/parse failures follow the reconcile policy above: fatal at boot, skipped on reload.
198205
- **MCP credentials** ride the transport (ADR-0024); for an http transport a resolved `auth` is folded into the request headers.
199-
- **A live `provider: 'mcp'` showcase demo** remains deferred: materialization needs a reachable MCP server, and the natural in-repo target (`@objectstack/mcp`, the platform's own MCP endpoint) is not listening until after the automation plugin's `start()` — the demo would deterministically boot degraded and heal seconds later, making the dogfood CI gate timing-sensitive. It should land together with an in-repo MCP fixture server (or an explicit boot-ordering story for self-connection).
200-
- **The openapi provider's remote-URL spec fetch** still throws plain on network failure (fatal at boot). It can adopt the same `CONNECTOR_UPSTREAM_UNAVAILABLE` classification once operators ask for it — the mechanism is provider-agnostic.
206+
- **A live `provider: 'mcp'` showcase demo** landed with #3056 (#3062): the showcase points a declarative `mcp` instance at an in-repo stdio MCP fixture (`examples/app-showcase/scripts/mcp-fixture.mjs`), spawned under the host `declarativeStdio` allowlist, and a dogfood proof pins materialization + dispatch. Self-connection to the platform's own `@objectstack/mcp` endpoint (which is not listening until after the automation plugin's `start()`) stays out of scope — the fixture sidesteps the boot-ordering timing entirely.

packages/connectors/connector-openapi/src/openapi-provider.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { describe, it, expect } from 'vitest';
77
import type { ConnectorProviderContext } from '@objectstack/spec/integration';
8+
import { isConnectorUpstreamUnavailable } from '@objectstack/spec/integration';
89
import { createOpenApiProviderFactory, OPENAPI_PROVIDER_KEY } from './openapi-provider.js';
910

1011
const petstore = {
@@ -96,3 +97,73 @@ describe('openapi provider factory (ADR-0097)', () => {
9697
).rejects.toThrow(/no package file access/);
9798
});
9899
});
100+
101+
// ── #3049 follow-up: remote-URL fetch fault classification ──────────────────
102+
//
103+
// A remote spec URL that is unreachable / transiently failing is an OPERATIONAL
104+
// fault: the factory throws the CONNECTOR_UPSTREAM_UNAVAILABLE marker so the
105+
// materializer degrades + retries the instance instead of failing boot
106+
// (symmetric with connector-mcp's connect path). A wrong URL (non-retryable
107+
// 4xx) or an unparseable document stays a plain, fatal configuration fault.
108+
109+
describe('openapi provider — remote spec fetch fault classification (#3049)', () => {
110+
const url = 'https://petstore.example.com/openapi.json';
111+
const cfg = { providerConfig: { spec: url } };
112+
113+
it('classifies a network failure (fetch rejects) as upstream-unavailable, keeping the cause', async () => {
114+
const boom = new Error('connect ECONNREFUSED 93.184.216.34:443');
115+
const fetchImpl = (async () => { throw boom; }) as unknown as typeof fetch;
116+
const factory = createOpenApiProviderFactory({ fetchImpl });
117+
118+
const err = await factory(ctx(cfg)).then(
119+
() => { throw new Error('expected rejection'); },
120+
(e: unknown) => e,
121+
);
122+
expect(isConnectorUpstreamUnavailable(err)).toBe(true);
123+
expect((err as Error).message).toMatch(/'pets' could not reach spec URL/);
124+
expect((err as Error).message).toContain('ECONNREFUSED');
125+
expect((err as { cause?: unknown }).cause).toBe(boom);
126+
});
127+
128+
it.each([408, 429, 500, 502, 503, 504])(
129+
'classifies a transient HTTP %i as upstream-unavailable (retryable)',
130+
async (status) => {
131+
const fetchImpl = (async () => ({ ok: false, status, json: async () => ({}) }) as unknown as Response) as unknown as typeof fetch;
132+
const factory = createOpenApiProviderFactory({ fetchImpl });
133+
const err = await factory(ctx(cfg)).then(() => { throw new Error('expected rejection'); }, (e: unknown) => e);
134+
expect(isConnectorUpstreamUnavailable(err), `HTTP ${status} should degrade`).toBe(true);
135+
expect((err as Error).message).toContain(String(status));
136+
},
137+
);
138+
139+
it.each([400, 401, 403, 404, 410])(
140+
'keeps a non-retryable HTTP %i a plain (fatal) configuration fault',
141+
async (status) => {
142+
const fetchImpl = (async () => ({ ok: false, status, json: async () => ({}) }) as unknown as Response) as unknown as typeof fetch;
143+
const factory = createOpenApiProviderFactory({ fetchImpl });
144+
const err = await factory(ctx(cfg)).then(() => { throw new Error('expected rejection'); }, (e: unknown) => e);
145+
expect(isConnectorUpstreamUnavailable(err), `HTTP ${status} should stay fatal`).toBe(false);
146+
expect((err as Error).message).toMatch(/failed to fetch spec/);
147+
},
148+
);
149+
150+
it('keeps a 2xx-but-unparseable body a plain (fatal) content fault, not upstream-unavailable', async () => {
151+
const fetchImpl = (async () => ({
152+
ok: true,
153+
status: 200,
154+
json: async () => { throw new SyntaxError('Unexpected token < in JSON'); },
155+
}) as unknown as Response) as unknown as typeof fetch;
156+
const factory = createOpenApiProviderFactory({ fetchImpl });
157+
const err = await factory(ctx(cfg)).then(() => { throw new Error('expected rejection'); }, (e: unknown) => e);
158+
expect(isConnectorUpstreamUnavailable(err)).toBe(false);
159+
expect((err as Error).message).toMatch(/not a parseable.*OpenAPI JSON document/s);
160+
});
161+
162+
it('keeps a 2xx non-object body (array) a plain (fatal) content fault', async () => {
163+
const fetchImpl = (async () => ({ ok: true, status: 200, json: async () => [1, 2] }) as unknown as Response) as unknown as typeof fetch;
164+
const factory = createOpenApiProviderFactory({ fetchImpl });
165+
const err = await factory(ctx(cfg)).then(() => { throw new Error('expected rejection'); }, (e: unknown) => e);
166+
expect(isConnectorUpstreamUnavailable(err)).toBe(false);
167+
expect((err as Error).message).toMatch(/not a parseable.*OpenAPI JSON document/s);
168+
});
169+
});

packages/connectors/connector-openapi/src/openapi-provider.ts

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
ConnectorProviderFactory,
66
ResolvedConnectorAuth,
77
} from '@objectstack/spec/integration';
8+
import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration';
89
import {
910
createOpenApiConnector,
1011
type OpenApiDocument,
@@ -17,6 +18,17 @@ import {
1718
*/
1819
export const OPENAPI_PROVIDER_KEY = 'openapi';
1920

21+
/**
22+
* HTTP statuses treated as **transient** when fetching a remote spec at boot
23+
* (#3049 follow-up): a timeout / rate-limit / 5xx means the spec endpoint is
24+
* momentarily unhealthy, not that the connector is misconfigured — so the
25+
* instance degrades and retries rather than aborting boot. Mirrors the
26+
* connector request-retry convention (`retryableStatusCodes` default in
27+
* `ConnectorSchema`). Any OTHER non-2xx (400 / 401 / 403 / 404 / 410 …) means
28+
* the request itself is wrong — a configuration fault that stays fatal.
29+
*/
30+
const SPEC_FETCH_RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
31+
2032
/** Injectable dependencies for {@link createOpenApiProviderFactory} (tests). */
2133
export interface OpenApiProviderDeps {
2234
/** Injected fetch implementation (spec fetch + request transport); defaults to global `fetch`. */
@@ -29,6 +41,10 @@ interface OpenApiProviderConfig {
2941
* The OpenAPI 3.x document: an inline object, an http(s) URL to fetch at
3042
* boot, or a file path resolved relative to the declaring stack/package root
3143
* (`'./billing-openapi.json'`, #3016).
44+
*
45+
* A remote URL that is unreachable / transiently failing (network error,
46+
* 408 / 429 / 5xx) degrades the instance and retries (#3049); a wrong URL
47+
* (non-retryable 4xx) or an unparseable document stays a fatal config fault.
3248
*/
3349
spec?: unknown;
3450
/** Override the base URL (else the document's `servers[0].url`). */
@@ -41,9 +57,14 @@ interface OpenApiProviderConfig {
4157
* form used by the showcase), an http(s) URL fetched at materialization, or a
4258
* **file path** read through the host's `ctx.loadPackageFile` — which resolves
4359
* it relative to the declaring stack/package root and confines the read to
44-
* that root (absolute / `..`-escaping paths are rejected there). Every failure
45-
* throws, so the materializer's reconcile policy applies: fatal at boot, the
46-
* entry is skipped on reload.
60+
* that root (absolute / `..`-escaping paths are rejected there).
61+
*
62+
* Fault classification (#3049 seam, symmetric with connector-mcp's connect
63+
* path): a remote spec URL that is unreachable or transiently failing throws
64+
* {@link ConnectorUpstreamUnavailableError} — the materializer degrades the
65+
* instance and retries. Every OTHER failure (missing spec, a wrong URL,
66+
* an unparseable document, no host file access) throws a plain error, which
67+
* stays fatal at boot / a skipped entry on reload.
4768
*/
4869
async function loadOpenApiDocument(
4970
spec: unknown,
@@ -57,13 +78,48 @@ async function loadOpenApiDocument(
5778
if (typeof spec === 'string' && spec.length > 0) {
5879
if (/^https?:\/\//i.test(spec)) {
5980
const doFetch = fetchImpl ?? fetch;
60-
const res = await doFetch(spec);
81+
let res: Response;
82+
try {
83+
res = await doFetch(spec);
84+
} catch (err) {
85+
// The spec endpoint is unreachable — DNS / connection refused /
86+
// timeout / network error. Operational, not a config mistake: degrade
87+
// + retry rather than fail boot.
88+
throw new ConnectorUpstreamUnavailableError(
89+
`connector-openapi provider: connector '${connectorName}' could not reach spec URL '${spec}': ${(err as Error).message}`,
90+
{ cause: err },
91+
);
92+
}
6193
if (!res.ok) {
94+
if (SPEC_FETCH_RETRYABLE_STATUS.has(res.status)) {
95+
// A transient server-side status (timeout / rate-limit / 5xx) — the
96+
// endpoint is momentarily unhealthy, so treat it as upstream-unavailable.
97+
throw new ConnectorUpstreamUnavailableError(
98+
`connector-openapi provider: connector '${connectorName}' got a transient HTTP ${res.status} fetching spec '${spec}'.`,
99+
);
100+
}
101+
// A non-retryable status (400 / 401 / 403 / 404 / 410 …) — the request
102+
// is wrong (bad URL, missing/insufficient auth for the spec endpoint):
103+
// a configuration fault that stays fatal.
62104
throw new Error(
63105
`connector-openapi provider: connector '${connectorName}' failed to fetch spec '${spec}' (HTTP ${res.status}).`,
64106
);
65107
}
66-
return (await res.json()) as OpenApiDocument;
108+
// A 2xx with an unparseable / non-object body is a content fault (the
109+
// endpoint served the wrong thing) — fatal, symmetric with the file-path
110+
// parse below.
111+
try {
112+
const parsed: unknown = await res.json();
113+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
114+
throw new Error('not a JSON object');
115+
}
116+
return parsed as OpenApiDocument;
117+
} catch (err) {
118+
throw new Error(
119+
`connector-openapi provider: connector '${connectorName}' fetched spec '${spec}' but it is not a parseable ` +
120+
`OpenAPI JSON document: ${(err as Error).message}`,
121+
);
122+
}
67123
}
68124
// File path — dereferenced through the host capability so resolution stays
69125
// anchored to (and confined within) the declaring stack/package root.
@@ -109,8 +165,10 @@ async function loadOpenApiDocument(
109165
* generates for a hand-wired OpenAPI connector — one action per operation over a
110166
* static-auth HTTP transport, with the resolved `auth` applied.
111167
*
112-
* Hard-fails on invalid config (missing/unfetchable spec, no base URL), so a
113-
* misconfigured instance fails boot loudly.
168+
* Hard-fails on invalid config (missing spec, a wrong spec URL, an unparseable
169+
* document, a bad base URL), so a misconfigured instance fails boot loudly. A
170+
* remote spec URL that is merely unreachable / transiently failing instead
171+
* degrades the instance and retries (#3049) — see {@link loadOpenApiDocument}.
114172
*/
115173
export function createOpenApiProviderFactory(deps: OpenApiProviderDeps = {}): ConnectorProviderFactory {
116174
return async (ctx) => {

0 commit comments

Comments
 (0)