fix: surface TOC extraction failures instead of writing a partial toc… - #303
fix: surface TOC extraction failures instead of writing a partial toc…#303MuhammadRafay1 wants to merge 3 commits into
Conversation
….yml `portal toc new` fell back to the default TOC whenever endpoint extraction failed, so a logged-out user (or any auth/network/spec error) got a toc.yml missing its Endpoints, Events and Models sections written over a possibly valid file, reported as success. Propagate the ServiceError instead: report the reason and leave the file alone. The no-spec-directory case is still an expected fallback, not a failure. The command also hung after printing its outro: stream endpoints hand back an undrained IncomingMessage on ApiError.body, whose open socket keeps the event loop alive since outro() only sets process.exitCode. Discard it centrally in handleServiceError, after the error has been mapped, plus in the two services that catch ApiError without going through it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| if (apiError.statusCode === 400) { | ||
| return "Your API Definition is invalid. Please use the APIMatic VS Code Extension to fix the errors and try again."; | ||
| } else if (apiError.statusCode === 401) { | ||
| const message = JSON.parse(apiError.body as string).message; |
There was a problem hiding this comment.
Unguarded JSON.parse on an Error Path
The following line performs an unguarded JSON parse on an error path:
const message = JSON.parse(apiError.body as string).message;There are three common scenarios where this throws:
| Input | Result |
|---|---|
Stream body (401 from downloadTransformedFile, a callAsStream endpoint) |
JSON.parse stringifies the IncomingMessage to "[object Object]" → SyntaxError (confirmed) |
| Empty body | JSON.parse("") → SyntaxError |
| Non-JSON body (gateway/proxy HTML, e.g. a corporate MITM 401 page) | SyntaxError |
The third case is the realistic one. Enterprise users behind an authenticating proxy often receive an HTML error page instead of JSON.
Consequence
The SyntaxError escapes handleTransformationErrors, then escapes the catch block in transformViaFile (because the throw occurs inside err(await this.handleTransformationErrors(error))), and ultimately surfaces as an unhandled rejection.
As a result:
- Users receive a raw stack trace instead of the intended error message.
outro(result)is skipped.- The process exits with the wrong exit code.
- Failure telemetry is not recorded correctly.
Severity
Medium — this does not cause data loss or corruption, but it degrades an already-failing path into an unhandled exception and breaks expected error reporting.
Why This Belongs in This PR
Although this issue is pre-existing, this PR modifies these exact lines by wrapping them in try/finally and adds a comment asserting that this branch safely reads the body.
Additionally:
- The safe-parse pattern already exists in the sibling file (
validation-service.ts, lines 225–234). unauthorizedWithHintalready acceptsstring | null.
This is a small, low-risk fix (roughly five lines) that aligns this code path with the existing implementation.
`JSON.parse(apiError.body as string)` assumed a JSON string body. A stream endpoint hands back an `IncomingMessage` (stringified to "[object Object]"), an empty body parses to nothing, and an authenticating proxy can answer with an HTML error page — each throws a SyntaxError. It escaped `handleTransformationErrors` and the catch in `transformViaFile`, because the call sits inside the `err(await ...)` that catch builds, so it surfaced as an unhandled rejection: a raw stack trace instead of the message, no outro, wrong exit code, and no failure telemetry. Parse defensively and fall back to `unauthorizedWithHint`'s own default message, matching the safe-parse pattern already in `validation-service`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generator only enumerates endpoints, models and events when it has data: `getEndpointsSection` short-circuits on `data.size === 0` before it looks at the expand flag, and the models and events sections drop out entirely. So `portal toc new --expand-endpoints` against a build directory with no `spec/` wrote a collapsed TOC, ignored the flag and exited 0 — the flag help text documents the requirement but nothing enforced it. Fail instead when any expand flag is set and `spec/` is absent, naming the flags that cannot be honoured. Checked before the overwrite prompt so a run that cannot succeed does not ask to replace a file first. Unchanged: with no expand flags a missing `spec/` still falls back to the default TOC, since that is the correct output when there is nothing to expand. Auth is deliberately not part of this check — with no spec no request is made, so reporting an unauthorized error here would name a cause that did not occur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|



The issue
portal toc newtreated every failure to extract endpoint data as a reason to fall back to the default TOC:TocComponents.empty()means "no Endpoints, Events or Models sections". So when a user was logged out — or hit a network error, an expired key, or an invalid spec — the command would:toc.ymlcontaining only custom content, overwriting a possibly valid existing TOC,The hang had a separate cause.
generateTocDatais acallAsStreamendpoint, so on failure the SDK hands back an undrainedIncomingMessageonApiError.body. Its socket stays open and keeps the Node event loop alive, and sinceoutro()only setsprocess.exitCode— we never callprocess.exit()— the process printed its outro and then sat there.The fix
1. Propagate the error instead of silently degrading
actions/portal/toc/new-toc.tsThe inner IIFE now returns
Result<TocComponents, ServiceError>. A failed extraction returnserr(...); the caller reports the reason (auth, network, invalid spec) and returnsActionResult.failed(), leavingtoc.ymluntouched.The genuinely-expected case is preserved: no
spec/directory still falls back to the default TOC, because there is nothing to extract and the default is the correct output. Only a failed extraction is now fatal.specFileStream.close()moved into afinallyso it is released on both paths.2. Release stream error bodies
infrastructure/service-error.tsNew exported
discardStreamBody(error), called fromhandleServiceErroraftermapApiErrorhas run, so mapping still readserror.result. Non-stream bodies are strings or Blobs with nodestroy, so the check istypeof body?.destroy === "function".Applied in the two services that catch
ApiErrorwithout routing throughhandleServiceError:transformation-service.ts— in afinally, so the 401 branch can stillJSON.parsethe body before it is discarded.validation-service.ts— before the switch, since no branch there reads the body.PortalService.generatePortal's 422 path is deliberately untouched: it returnserror.bodyas the validation report atportal-service.ts:110-111, short-circuiting beforehandleServiceError, so that stream is never destroyed.3. Renamed prompt method
PortalNewTocPrompts.logError→tocExtractionFailed, to name what it reports.Behavior change
portal toc newnow fails rather than writing a partial TOC when endpoint extraction fails — including when the user is logged out. Previously it exited 0 with a degraded file.PortalNewTocActionis only used bycommands/portal/toc/new.ts, so nothing else is affected.