From 8eeea169db0d02cf09602db01ce3fdf9a3e5efb3 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Tue, 8 Sep 2026 12:54:36 +0000 Subject: [PATCH] feat: enhance error handling for unexpected response bodies in SaaS gateway --- .../base-saas-gateway.service.ts | 56 ++++++++++++++++--- .../unit-tests/saas-company-gateway.test.ts | 33 +++++++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/backend/src/microservices/gateways/saas-gateway.ts/base-saas-gateway.service.ts b/backend/src/microservices/gateways/saas-gateway.ts/base-saas-gateway.service.ts index 2e33aaa13..ef0c7cfdf 100644 --- a/backend/src/microservices/gateways/saas-gateway.ts/base-saas-gateway.service.ts +++ b/backend/src/microservices/gateways/saas-gateway.ts/base-saas-gateway.service.ts @@ -37,9 +37,11 @@ export class BaseSaasGatewayService { }, }); - const responseBody = await this.bodyToJSON(res); + const { body: responseBody, parseFailure } = await this.readBody(res); if (res.status >= 400) { - this.reportFailedRequest(method, patch, res.status, responseBody); + this.reportFailedRequest(method, patch, res.status, responseBody, parseFailure); + } else if (parseFailure) { + this.reportUnexpectedBody(method, patch, res, parseFailure); } return { status: res.status, @@ -63,8 +65,10 @@ export class BaseSaasGatewayService { patch: string, status: number, body: Record, + parseFailure?: string, ): void { - const message = `SaaS request ${method} ${patch} failed: HTTP ${status}${describeSaasErrorBody(body)}`; + const bodyNote = describeSaasErrorBody(body) || (parseFailure ? ` (${parseFailure})` : ''); + const message = `SaaS request ${method} ${patch} failed: HTTP ${status}${bodyNote}`; this.logger.warn(message); const route = normalizeSaasPath(patch); Sentry.withScope((scope) => { @@ -77,14 +81,50 @@ export class BaseSaasGatewayService { }); } - private async bodyToJSON(res: Response): Promise> { - if (!res.body) { - return {}; + // A 2xx whose body is not JSON never came from a saas controller. The usual cause is + // SAAS_URL pointing at something else on the same host — e.g. the SPA's nginx, whose + // history-mode fallback answers 200 + index.html for any unknown path — or a redirect + // that fetch followed. Content type, final URL and a body snippet pin that down. + private reportUnexpectedBody(method: SaaSRequestMethod, patch: string, res: Response, parseFailure: string): void { + const contentType = res.headers.get('content-type') ?? 'none'; + const message = + `SaaS request ${method} ${patch} returned HTTP ${res.status} but ${parseFailure} ` + + `(content-type: ${contentType}; final URL: ${res.url || 'n/a'}; redirected: ${res.redirected}) — ` + + `SAAS_URL (${this.baseSaaSUrl}) does not seem to reach the saas API`; + this.logger.error(message); + const route = normalizeSaasPath(patch); + Sentry.withScope((scope) => { + scope.setLevel('error'); + scope.setTag('saas_method', method); + scope.setTag('saas_route', route); + scope.setTag('saas_status', String(res.status)); + scope.setTag('saas_content_type', contentType); + scope.setFingerprint(['saas-request-non-json-body', method, route]); + Sentry.captureMessage(message); + }); + } + + // Parses the body as JSON; on failure returns `{}` (the historical contract every caller + // relies on) plus a short description of what was actually there, for the reports above. + private async readBody(res: Response): Promise<{ body: Record; parseFailure?: string }> { + let text: string; + try { + text = await res.text(); + } catch (error) { + return { body: {}, parseFailure: `body could not be read: ${getErrorMessage(error)}` }; + } + if (!text.trim()) { + return { body: {}, parseFailure: 'the body is empty' }; } try { - return await res.json(); + const parsed: unknown = JSON.parse(text); + if (parsed !== null && typeof parsed === 'object') { + return { body: parsed as Record }; + } + return { body: {}, parseFailure: `the body is JSON but not an object (${typeof parsed})` }; } catch (_error) { - return {}; + const snippet = text.slice(0, 160).replace(/\s+/g, ' '); + return { body: {}, parseFailure: `the body is not JSON: "${snippet}"` }; } } } diff --git a/backend/test/ava-tests/unit-tests/saas-company-gateway.test.ts b/backend/test/ava-tests/unit-tests/saas-company-gateway.test.ts index 98afe9d27..64abe88be 100644 --- a/backend/test/ava-tests/unit-tests/saas-company-gateway.test.ts +++ b/backend/test/ava-tests/unit-tests/saas-company-gateway.test.ts @@ -89,6 +89,39 @@ test.serial('2xx without the expected fields -> null, logged as an unexpected bo t.true(warnings.some((message) => message.includes('unexpected body') && message.includes('success'))); }); +test.serial( + '200 with an HTML page (SAAS_URL hitting an SPA fallback) -> null, content type + snippet logged as error', + async (t) => { + stubFetch( + async () => + new Response('SiteNova', { + status: 200, + headers: { 'content-type': 'text/html; charset=utf-8' }, + }), + ); + const gateway = new SaasCompanyGatewayService(); + t.is(await gateway.getCompanyInfo(COMPANY_ID), null); + const errors = captured.filter((entry) => entry.level === 'error').map((entry) => entry.message); + t.true( + errors.some( + (message) => + message.includes('HTTP 200') && + message.includes('text/html') && + message.includes('') && + message.includes('http://saas.unit.test'), + ), + `expected content type, snippet and SAAS_URL in the error log, got: ${JSON.stringify(errors)}`, + ); + }, +); + +test.serial('200 with an empty body -> null, reported as empty', async (t) => { + stubFetch(async () => new Response('', { status: 200 })); + const gateway = new SaasCompanyGatewayService(); + t.is(await gateway.getCompanyInfo(COMPANY_ID), null); + t.true(captured.some((entry) => entry.level === 'error' && entry.message.includes('the body is empty'))); +}); + test.serial('fetch throwing (SAAS_URL unreachable) -> rethrows, and the target base URL is logged', async (t) => { stubFetch(async () => { throw new TypeError('fetch failed');