diff --git a/app/client/src/sagas/ActionExecution/PluginActionSaga.ts b/app/client/src/sagas/ActionExecution/PluginActionSaga.ts index 00fd7bffb4d9..e1db4449d5b8 100644 --- a/app/client/src/sagas/ActionExecution/PluginActionSaga.ts +++ b/app/client/src/sagas/ActionExecution/PluginActionSaga.ts @@ -113,6 +113,7 @@ import { FileDataTypes } from "WidgetProvider/types"; import { hideDebuggerErrors } from "actions/debuggerActions"; import { ActionValidationError, + extractExecutionErrorMessage, getErrorAsString, PluginActionExecutionError, PluginTriggerFailureError, @@ -873,6 +874,18 @@ export function* runActionSaga( } : undefined; + // When the server response was lost (network/timeout/parse failure), the + // caught PluginActionExecutionError carries the specific transport message + // from extractExecutionErrorMessage. Surface it instead of the generic + // fallback so users and the debugger console see what actually went wrong. + const transportError = + error.message && error.message !== "An unexpected error occurred" + ? { + name: "PluginExecutionError", + message: error.message, + } + : undefined; + const defaultError = { name: "PluginExecutionError", message: "An unexpected error occurred", @@ -888,7 +901,11 @@ export function* runActionSaga( if (isError) { error = - readableError || payloadBodyError || clientDefinedError || defaultError; + readableError || + payloadBodyError || + clientDefinedError || + transportError || + defaultError; // In case of debugger, both the current error message // and the readableError needs to be present, @@ -1551,7 +1568,10 @@ function* executePluginActionSaga( ); } - throw new PluginActionExecutionError("Response not valid", false); + throw new PluginActionExecutionError( + extractExecutionErrorMessage(e), + false, + ); } } diff --git a/app/client/src/sagas/ActionExecution/errorUtils.test.ts b/app/client/src/sagas/ActionExecution/errorUtils.test.ts new file mode 100644 index 000000000000..c35fd94dfb0e --- /dev/null +++ b/app/client/src/sagas/ActionExecution/errorUtils.test.ts @@ -0,0 +1,74 @@ +import { extractExecutionErrorMessage } from "./errorUtils"; + +describe("extractExecutionErrorMessage", () => { + it("returns timeout message for Axios ECONNABORTED with timeout pattern", () => { + const axiosError = Object.assign(new Error("timeout of 10000ms exceeded"), { + code: "ECONNABORTED", + isAxiosError: true, + }); + + expect(extractExecutionErrorMessage(axiosError)).toBe( + "Action execution timed out. Try increasing the timeout in the action settings.", + ); + }); + + it("returns network error message for Axios Network Error", () => { + const axiosError = Object.assign(new Error("Network Error"), { + isAxiosError: true, + }); + + expect(extractExecutionErrorMessage(axiosError)).toBe( + "Network error: could not reach the Appsmith server. Check your connection.", + ); + }); + + it("returns Axios message for other Axios errors", () => { + const axiosError = Object.assign( + new Error("Request failed with status code 502"), + { isAxiosError: true }, + ); + + expect(extractExecutionErrorMessage(axiosError)).toBe( + "Request failed: Request failed with status code 502", + ); + }); + + it("returns Axios message for ECONNABORTED without timeout pattern", () => { + const axiosError = Object.assign(new Error("connection aborted"), { + code: "ECONNABORTED", + }); + + expect(extractExecutionErrorMessage(axiosError)).toBe( + "Request failed: connection aborted", + ); + }); + + it("returns server error message from validateResponse errors", () => { + const serverError = new Error("Organization not found"); + + expect(extractExecutionErrorMessage(serverError)).toBe( + "Organization not found", + ); + }); + + it("returns 'Response not valid' for non-Error values", () => { + expect(extractExecutionErrorMessage("string error")).toBe( + "Response not valid", + ); + expect(extractExecutionErrorMessage(null)).toBe("Response not valid"); + expect(extractExecutionErrorMessage(undefined)).toBe("Response not valid"); + expect(extractExecutionErrorMessage(42)).toBe("Response not valid"); + }); + + it("returns 'Response not valid' for Error with empty message", () => { + expect(extractExecutionErrorMessage(new Error(""))).toBe( + "Response not valid", + ); + }); + + it("returns the error message for a plain Error", () => { + const error = new Error("Something went wrong"); + + expect(extractExecutionErrorMessage(error)).toBe("Something went wrong"); + }); +}); diff --git a/app/client/src/sagas/ActionExecution/errorUtils.ts b/app/client/src/sagas/ActionExecution/errorUtils.ts index 2f2637f937b9..96457215e3cb 100644 --- a/app/client/src/sagas/ActionExecution/errorUtils.ts +++ b/app/client/src/sagas/ActionExecution/errorUtils.ts @@ -120,3 +120,43 @@ export class UserCancelledActionExecutionError extends PluginActionExecutionErro export const getErrorAsString = (error: unknown): string => { return isString(error) ? error : JSON.stringify(error); }; + +const AXIOS_TIMEOUT_REGEX = /timeout of \d+ms exceeded/; + +/** + * Extracts a meaningful, user-facing error message from the caught exception + * in the action execution path. Categorises Axios transport errors, server + * envelope errors (from validateResponse), and falls back gracefully. + * + * Security: only surfaces our own server messages or Axios transport strings — + * never raw upstream API bodies or credentials. + */ +export function extractExecutionErrorMessage(e: unknown): string { + if (!(e instanceof Error)) { + return "Response not valid"; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const axiosLike = e as any; + + if (axiosLike.isAxiosError || axiosLike.code === "ECONNABORTED") { + if ( + axiosLike.code === "ECONNABORTED" && + AXIOS_TIMEOUT_REGEX.test(axiosLike.message) + ) { + return "Action execution timed out. Try increasing the timeout in the action settings."; + } + + if (axiosLike.message === "Network Error") { + return "Network error: could not reach the Appsmith server. Check your connection."; + } + + return `Request failed: ${axiosLike.message || "unknown transport error"}`; + } + + if (e.message) { + return e.message; + } + + return "Response not valid"; +} diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/helpers/restApiUtils/helpers/RestAPIActivateUtils.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/helpers/restApiUtils/helpers/RestAPIActivateUtils.java index 2d40fa2107d8..184c651c2769 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/helpers/restApiUtils/helpers/RestAPIActivateUtils.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/helpers/restApiUtils/helpers/RestAPIActivateUtils.java @@ -19,6 +19,7 @@ import io.jsonwebtoken.security.Keys; import io.micrometer.observation.ObservationRegistry; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatusCode; @@ -51,6 +52,7 @@ import static org.apache.commons.lang3.StringUtils.isNotEmpty; import static org.springframework.util.CollectionUtils.isEmpty; +@Slf4j @NoArgsConstructor public class RestAPIActivateUtils { @@ -146,7 +148,8 @@ public Mono triggerApiCall( result.setBody(objectMapper.readTree(jsonBody)); responseDataType = ResponseDataType.JSON; } catch (IOException e) { - System.out.println("Unable to parse response JSON. Setting response body as string."); + log.debug( + "Response declared Content-Type application/json but body is not valid JSON. Falling back to string representation."); String bodyString = new String(body, StandardCharsets.UTF_8); result.setBody(bodyString.trim()); diff --git a/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/plugins/RestApiPlugin.java b/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/plugins/RestApiPlugin.java index 152d881aee8f..188d2faa574c 100644 --- a/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/plugins/RestApiPlugin.java +++ b/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/plugins/RestApiPlugin.java @@ -226,10 +226,11 @@ public Mono executeCommon( errorResult.setRequest(requestCaptureFilter.populateRequestFields( actionExecutionRequest, isBodySentWithApiRequest, datasourceConfiguration)); errorResult.setIsExecutionSuccess(false); - log.debug(String.format( - "An error has occurred while trying to run the API query for url: %s, path: %s", - datasourceConfiguration.getUrl(), actionConfiguration.getPath())); - error.printStackTrace(); + log.debug( + "REST API execution failed for method: {}, path: {}", + actionExecutionRequest.getHttpMethod(), + actionConfiguration.getPath(), + error); if (!(error instanceof AppsmithPluginException)) { error = new AppsmithPluginException( RestApiPluginError.API_EXECUTION_FAILED, diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/ActionExecutionSolutionCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/ActionExecutionSolutionCEImpl.java index 6f24533717e5..b156e6b55f82 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/ActionExecutionSolutionCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/ActionExecutionSolutionCEImpl.java @@ -391,7 +391,7 @@ public Mono executeAction( return actionExecutionResultMono .zipWith(editorConfigLabelMapMono, (result, labelMap) -> { if (TRUE.equals(executeActionDTO.getViewMode())) { - result.setRequest(null); + sanitizeRequestForViewMode(result); } else if (result.getRequest() != null && result.getRequest().getRequestParams() != null) { transformRequestParams(result, labelMap); @@ -871,8 +871,10 @@ protected Mono verifyDatasourceAndMakeRequest( return new AppsmithPluginException(AppsmithPluginError.STALE_CONNECTION_ERROR, e.getMessage()); } else { log.debug( - "{}: In the action execution error mode.", - Thread.currentThread().getName(), + "Action execution failed for action '{}' (id: {}): {}", + actionDTO.getName(), + actionDTO.getId(), + error.getMessage(), error); return error; } @@ -881,6 +883,11 @@ protected Mono verifyDatasourceAndMakeRequest( protected Function> executionExceptionHandler(ActionDTO actionDTO) { return error -> { + log.warn( + "Handling execution error for action '{}' (id: {}): {}", + actionDTO.getName(), + actionDTO.getId(), + error.getMessage()); ActionExecutionResult result = new ActionExecutionResult(); result.setErrorInfo(error); result.setIsExecutionSuccess(false); @@ -1093,6 +1100,23 @@ private ActionExecutionResult addDataTypesAndSetSuggestedWidget(ActionExecutionR return result; } + /** + * In view/published mode, strip potentially sensitive fields from the request object + * (headers, body, URL, params) while retaining safe metadata (action ID, timestamp, + * HTTP method) so users can correlate which action failed and when. + */ + void sanitizeRequestForViewMode(ActionExecutionResult result) { + ActionExecutionRequest original = result.getRequest(); + if (original == null) { + return; + } + ActionExecutionRequest sanitized = new ActionExecutionRequest(); + sanitized.setActionId(original.getActionId()); + sanitized.setRequestedAt(original.getRequestedAt()); + sanitized.setHttpMethod(original.getHttpMethod()); + result.setRequest(sanitized); + } + /** * Since we're loading the application and other details from DB *only* for analytics, we check if analytics is * active before making the call to DB. diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ce/ActionExecutionSolutionCEImplTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ce/ActionExecutionSolutionCEImplTest.java index d5e077703ffa..e3a0fd1a6132 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ce/ActionExecutionSolutionCEImplTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ce/ActionExecutionSolutionCEImplTest.java @@ -463,4 +463,44 @@ public void testEnrichExecutionParams_withBlobReference_performsSubstitutionCorr }) .verifyComplete(); } + + @Test + void sanitizeRequestForViewMode_retainsSafeFieldsAndStripsSensitiveOnes() { + var original = new com.appsmith.external.models.ActionExecutionRequest(); + original.setActionId("action-123"); + original.setRequestedAt(java.time.Instant.parse("2026-04-30T12:00:00Z")); + original.setHttpMethod(HttpMethod.POST); + original.setUrl("https://internal-api.example.com/secret?key=abc"); + original.setBody("{\"password\": \"s3cret\"}"); + original.setHeaders(Map.of("Authorization", "Bearer token")); + original.setRequestParams(List.of("sensitive-param")); + + var result = new ActionExecutionResult(); + result.setRequest(original); + + actionExecutionSolution.sanitizeRequestForViewMode(result); + + var sanitized = result.getRequest(); + assertNotNull(sanitized); + assertEquals("action-123", sanitized.getActionId()); + assertEquals(java.time.Instant.parse("2026-04-30T12:00:00Z"), sanitized.getRequestedAt()); + assertEquals(HttpMethod.POST, sanitized.getHttpMethod()); + + // Sensitive fields must be stripped + assertEquals(null, sanitized.getUrl()); + assertEquals(null, sanitized.getBody()); + assertEquals(null, sanitized.getHeaders()); + assertEquals(null, sanitized.getRequestParams()); + assertEquals(null, sanitized.getExecutionParameters()); + } + + @Test + void sanitizeRequestForViewMode_handlesNullRequest() { + var result = new ActionExecutionResult(); + result.setRequest(null); + + actionExecutionSolution.sanitizeRequestForViewMode(result); + + assertEquals(null, result.getRequest()); + } }