Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions app/client/src/sagas/ActionExecution/PluginActionSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import { FileDataTypes } from "WidgetProvider/types";
import { hideDebuggerErrors } from "actions/debuggerActions";
import {
ActionValidationError,
extractExecutionErrorMessage,
getErrorAsString,
PluginActionExecutionError,
PluginTriggerFailureError,
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -1551,7 +1568,10 @@ function* executePluginActionSaga(
);
}

throw new PluginActionExecutionError("Response not valid", false);
throw new PluginActionExecutionError(
extractExecutionErrorMessage(e),
false,
);
}
}

Expand Down
74 changes: 74 additions & 0 deletions app/client/src/sagas/ActionExecution/errorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
40 changes: 40 additions & 0 deletions app/client/src/sagas/ActionExecution/errorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,6 +52,7 @@
import static org.apache.commons.lang3.StringUtils.isNotEmpty;
import static org.springframework.util.CollectionUtils.isEmpty;

@Slf4j
@NoArgsConstructor
public class RestAPIActivateUtils {

Expand Down Expand Up @@ -146,7 +148,8 @@ public Mono<ActionExecutionResult> 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());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,11 @@ public Mono<ActionExecutionResult> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ public Mono<ActionExecutionResult> 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);
Expand Down Expand Up @@ -871,8 +871,10 @@ protected Mono<ActionExecutionResult> 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;
}
Expand All @@ -881,6 +883,11 @@ protected Mono<ActionExecutionResult> verifyDatasourceAndMakeRequest(

protected Function<? super Throwable, Mono<ActionExecutionResult>> executionExceptionHandler(ActionDTO actionDTO) {
return error -> {
log.warn(
"Handling execution error for action '{}' (id: {}): {}",
actionDTO.getName(),
actionDTO.getId(),
error.getMessage());
Comment on lines +886 to +890
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Include throwable in WARN log to preserve stack trace

At Line 886, the warn log currently emits only error.getMessage(). In production (where DEBUG is often off), this drops stack-trace context for unexpected failures.

Suggested patch
-            log.warn(
-                    "Handling execution error for action '{}' (id: {}): {}",
-                    actionDTO.getName(),
-                    actionDTO.getId(),
-                    error.getMessage());
+            log.warn(
+                    "Handling execution error for action '{}' (id: {}): {}",
+                    actionDTO.getName(),
+                    actionDTO.getId(),
+                    error.getMessage(),
+                    error);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
log.warn(
"Handling execution error for action '{}' (id: {}): {}",
actionDTO.getName(),
actionDTO.getId(),
error.getMessage());
log.warn(
"Handling execution error for action '{}' (id: {}): {}",
actionDTO.getName(),
actionDTO.getId(),
error.getMessage(),
error);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/ActionExecutionSolutionCEImpl.java`
around lines 886 - 890, The WARN log in ActionExecutionSolutionCEImpl (inside
the execution error handling path) currently logs only error.getMessage(),
losing stack trace; update the log.warn call that references actionDTO.getName()
and actionDTO.getId() to pass the Throwable (error) as the last argument so the
logger preserves the full exception and stack trace when WARN/INFO level is
used.

ActionExecutionResult result = new ActionExecutionResult();
result.setErrorInfo(error);
result.setIsExecutionSuccess(false);
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Loading