From f977a6470d8dabfb73c3a754fc2719b61b8a7b64 Mon Sep 17 00:00:00 2001 From: Dave Shoup Date: Mon, 24 Aug 2026 11:03:19 -0400 Subject: [PATCH 1/2] make extractResponseBody() generic and accept raw Responses Co-Authored-By: Claude Opus 4.8 --- src/commands/flinkStatements.ts | 4 ++- src/commands/medusaCodeLens.ts | 2 +- src/commands/utils/uploadArtifactOrUDF.ts | 4 +-- src/errors.test.ts | 35 ++++++++++++++++---- src/errors.ts | 30 ++++++++++++----- src/flinkSql/flinkStatementResultsManager.ts | 2 +- src/viewProviders/flinkDatabase.ts | 2 +- 7 files changed, 58 insertions(+), 21 deletions(-) diff --git a/src/commands/flinkStatements.ts b/src/commands/flinkStatements.ts index 8bf426dd15..c796b3135b 100644 --- a/src/commands/flinkStatements.ts +++ b/src/commands/flinkStatements.ts @@ -248,7 +248,9 @@ export async function submitFlinkStatementCommand( if (isResponseError(err) && err.response.status === 400) { // Usually a bad SQL statement. // The error string should be JSON, have 'errors' as an array of objs with 'details' human readable messages. - const objFromResponse = await extractResponseBody(err); + const objFromResponse = await extractResponseBody<{ errors: [{ detail: string }] } | string>( + err, + ); let errorMessages: string; if (objFromResponse && typeof objFromResponse === "object" && "errors" in objFromResponse) { const responseErrors: { errors: [{ detail: string }] } = objFromResponse; diff --git a/src/commands/medusaCodeLens.ts b/src/commands/medusaCodeLens.ts index df64bbd1eb..3a1c8b3a5a 100644 --- a/src/commands/medusaCodeLens.ts +++ b/src/commands/medusaCodeLens.ts @@ -97,7 +97,7 @@ async function convertAvroSchemaToDataset(avroSchemaContent: string): Promise(error); const errorMessage = responseBody?.message || responseBody; throw new Error(`Medusa API error: ${errorMessage}`); } diff --git a/src/commands/utils/uploadArtifactOrUDF.ts b/src/commands/utils/uploadArtifactOrUDF.ts index 77d626feaa..763ac6251a 100644 --- a/src/commands/utils/uploadArtifactOrUDF.ts +++ b/src/commands/utils/uploadArtifactOrUDF.ts @@ -239,13 +239,13 @@ export function validateUdfInput( export async function buildUploadErrorMessage(err: unknown, base: string): Promise { let errorMessage = base; if (isResponseError(err)) { - const resp = await extractResponseBody(err); + const resp = await extractResponseBody<{ errors?: { detail: string }[] }>(err); if (err.response.status === 400) { // Bad request - a validation error we couldn't prevent. Only log to Sentry if unparseable. if (resp && typeof resp === "object" && "errors" in resp) { // Gather the detail(s) from all error(s) - const errors: Array<{ detail: string }> = resp.errors; + const errors = resp.errors ?? []; errorMessage = `${errorMessage} ${errors.map((e) => e.detail).join("\n")}`; } else { // Unexpected - log to Sentry for investigation diff --git a/src/errors.test.ts b/src/errors.test.ts index a6ba832304..902962b9ca 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -192,29 +192,52 @@ describe("errors.ts isTransientResponseError()", () => { }); describe("errors.ts extractResponseBody()", () => { - it("should return the response body as JSON if it is valid JSON", async () => { + it("should return a ResponseError's body as JSON if it is valid JSON", async () => { const embeddedObject = { message: "test" }; const error = createResponseError(400, "Bad Request", JSON.stringify(embeddedObject)); - const body = await extractResponseBody(error); + + const body = await extractResponseBody<{ message: string }>(error); + assert.deepStrictEqual(embeddedObject, body); }); - it("should return the response body as string if it is not valid JSON", async () => { + it("should return a ResponseError's body as a string if it is not valid JSON", async () => { const textResponse = "test random not-json {"; const error = createResponseError(400, "Bad Request", textResponse); + const body = await extractResponseBody(error); + + assert.strictEqual(body, textResponse); + }); + + it("should decode a raw Response passed directly, as JSON if it is valid JSON", async () => { + const embeddedObject = { message: "test" }; + const response = new Response(JSON.stringify(embeddedObject)); + + const body = await extractResponseBody<{ message: string }>(response); + + assert.deepStrictEqual(embeddedObject, body); + }); + + it("should decode a raw Response passed directly, as a string if it is not valid JSON", async () => { + const textResponse = "test random not-json {"; + const response = new Response(textResponse); + + const body = await extractResponseBody(response); + assert.strictEqual(body, textResponse); }); - it("should throw if the error is not a ResponseError", async () => { + it("should throw if given neither a ResponseError nor a Response", async () => { const error = new Error("test"); + await assert.rejects( async () => { - await extractResponseBody(error as any); + await extractResponseBody(error as unknown as Response); }, { name: "Error", - message: "extractResponseBody() called with non-ResponseError", + message: "extractResponseBody() called with neither a ResponseError nor a Response", }, ); }); diff --git a/src/errors.ts b/src/errors.ts index 6345ac0be3..08ac745635 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -64,23 +64,35 @@ export function isTransientResponseError(error: unknown): error is AnyResponseEr } /** - * If error is a response error, try to decode its response body - * from JSON and return the resulting object. + * Decode the body of a {@link Response}, whether passed directly (e.g. from a raw `fetch()`) or + * wrapped in one of our client {@link AnyResponseError}s. * - * If the response body is not JSON, return the text instead. + * The body is decoded as JSON; if it is not valid JSON, the raw text is returned instead. * - * If the error is not a response error, raise an error. + * The caller supplies the expected body shape via the type parameter (defaulting to `unknown`), so + * downstream code has some idea what to expect rather than an untyped `any`. + * + * @throws if given something that is neither a {@link Response} nor an {@link AnyResponseError}. */ -export async function extractResponseBody(error: AnyResponseError): Promise { - if (!isResponseError(error)) { - throw new Error("extractResponseBody() called with non-ResponseError"); +export async function extractResponseBody( + errorOrResponse: AnyResponseError | Response, +): Promise { + let response: Response; + if (isResponseError(errorOrResponse)) { + response = errorOrResponse.response; + } else if (errorOrResponse instanceof Response) { + response = errorOrResponse; + } else { + throw new Error("extractResponseBody() called with neither a ResponseError nor a Response"); } // Attempt to parse the response body as JSON, falling back to text if it fails try { - return await error.response.clone().json(); + const json: unknown = await response.clone().json(); + return json as T; } catch { - return await error.response.clone().text(); + const text: unknown = await response.clone().text(); + return text as T; } } diff --git a/src/flinkSql/flinkStatementResultsManager.ts b/src/flinkSql/flinkStatementResultsManager.ts index 87616d4089..13e2d4bfbe 100644 --- a/src/flinkSql/flinkStatementResultsManager.ts +++ b/src/flinkSql/flinkStatementResultsManager.ts @@ -354,7 +354,7 @@ export class FlinkStatementResultsManager { if (isResponseError(error)) { // clone before reading, so logError() below can still read the body for Sentry - const payload = await extractResponseBody(error); + const payload = await extractResponseBody<{ aborted?: boolean }>(error); if (!payload?.aborted) { const status = error.response.status; shouldComplete = status >= 400; diff --git a/src/viewProviders/flinkDatabase.ts b/src/viewProviders/flinkDatabase.ts index 7676817612..652cd4e744 100644 --- a/src/viewProviders/flinkDatabase.ts +++ b/src/viewProviders/flinkDatabase.ts @@ -389,7 +389,7 @@ export class FlinkDatabaseViewProvider extends ParentedBaseViewProvider< // only applies to loading artifacts, since all others are loaded via background statements // and won't throw HTTP response errors if (isResponseError(error)) { - const responseBody = await extractResponseBody(error); + const responseBody = await extractResponseBody<{ message?: string }>(error); errorMsg = responseBody?.message || JSON.stringify(responseBody, null, 2); errorLanguage = "json"; } From 71afaffc6d3e3d99982a720b30686fc3e3c4f5f7 Mon Sep 17 00:00:00 2001 From: Dave Shoup Date: Mon, 24 Aug 2026 12:11:00 -0400 Subject: [PATCH 2/2] type Flink error body as an array, not a 1-tuple Co-Authored-By: Claude Opus 4.8 --- src/commands/flinkStatements.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/flinkStatements.ts b/src/commands/flinkStatements.ts index c796b3135b..47ee731f66 100644 --- a/src/commands/flinkStatements.ts +++ b/src/commands/flinkStatements.ts @@ -248,12 +248,12 @@ export async function submitFlinkStatementCommand( if (isResponseError(err) && err.response.status === 400) { // Usually a bad SQL statement. // The error string should be JSON, have 'errors' as an array of objs with 'details' human readable messages. - const objFromResponse = await extractResponseBody<{ errors: [{ detail: string }] } | string>( + const objFromResponse = await extractResponseBody<{ errors: { detail: string }[] } | string>( err, ); let errorMessages: string; if (objFromResponse && typeof objFromResponse === "object" && "errors" in objFromResponse) { - const responseErrors: { errors: [{ detail: string }] } = objFromResponse; + const responseErrors: { errors: { detail: string }[] } = objFromResponse; logger.error(JSON.stringify(responseErrors, null, 2)); errorMessages = responseErrors.errors.map((e: { detail: string }) => e.detail).join("\n"); } else {