diff --git a/rewrite-core/src/main/java/org/openrewrite/RecipeError.java b/rewrite-core/src/main/java/org/openrewrite/RecipeError.java
new file mode 100644
index 0000000000..f7f8f70251
--- /dev/null
+++ b/rewrite-core/src/main/java/org/openrewrite/RecipeError.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite;
+
+import lombok.Getter;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Wraps an exception thrown by a recipe so that {@link ExecutionContext#getOnError()}
+ * consumers can attribute the failure to the specific nested recipe and source file
+ * that produced it. This is especially useful for large declarative recipes (e.g.
+ * thousands of nested recipes) where the underlying stack trace alone does not
+ * reveal which recipe ran into trouble.
+ */
+@Getter
+public class RecipeError extends RuntimeException {
+
+ /**
+ * The names of the recipes from root to leaf at the time of failure.
+ * The last element is the recipe that directly threw.
+ */
+ private final List recipeStack;
+
+ /**
+ * The path of the source file being processed when the error occurred,
+ * or {@code null} when the error was raised outside of a per-source context
+ * (for example, during scanning recipe generation).
+ */
+ private final @Nullable String sourcePath;
+
+ public RecipeError(List recipeStack, @Nullable String sourcePath, Throwable cause) {
+ super(cause);
+ this.recipeStack = Collections.unmodifiableList(recipeStack);
+ this.sourcePath = sourcePath;
+ }
+
+ /**
+ * @return the leaf recipe name (the recipe that directly threw), or
+ * {@code "unknown"} if the recipe stack is empty.
+ */
+ public String getRecipeName() {
+ return recipeStack.isEmpty() ? "unknown" : recipeStack.get(recipeStack.size() - 1);
+ }
+}
diff --git a/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java b/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java
index cff1904687..db8e7b4dc7 100644
--- a/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java
+++ b/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java
@@ -50,6 +50,7 @@
import java.util.function.UnaryOperator;
import static java.util.Collections.*;
+import static java.util.stream.Collectors.toList;
import static org.openrewrite.ExecutionContext.SCANNING_MUTATION_VALIDATION;
import static org.openrewrite.Recipe.PANIC;
@@ -151,7 +152,7 @@ public LSS scanSources(LSS sourceSet) {
return source;
});
} catch (Throwable t) {
- after = handleError(recipe, source, after, t);
+ after = handleError(recipeStack, source, after, t);
// We don't normally consider anything the scanning phase does to be a change
// But this simplifies error reporting so that exceptions can all be handled the same
assert after != null;
@@ -183,7 +184,7 @@ private void flushScanBatch(BatchState batch, SourceFile source) {
batch.rpc.batchVisit(source, ctx, rootCursor, batch.items);
} catch (Throwable t) {
if (!batch.recipeStacks.isEmpty()) {
- handleError(batch.recipeStacks.get(0).peek(), source, source, t);
+ handleError(batch.recipeStacks.get(0), source, source, t);
}
}
@@ -211,7 +212,7 @@ public LSS generateSources(LSS sourceSet) {
return source;
});
} catch (Throwable t) {
- handleError(recipe, source, source, t);
+ handleError(recipeStack, source, source, t);
}
}
}
@@ -243,7 +244,7 @@ public LSS generateSources(LSS sourceSet) {
madeChangesInThisCycle.add(recipe);
}
} catch (Throwable t) {
- handleError(recipe, new Quark(Tree.randomId(), Paths.get("error during generation"), Markers.EMPTY, null, null), null, t);
+ handleError(recipeStack, new Quark(Tree.randomId(), Paths.get("error during generation"), Markers.EMPTY, null, null), null, t);
}
}
return acc;
@@ -316,7 +317,7 @@ void clear() {
if (duration.compareTo(ctx.getMessage(ExecutionContext.RUN_TIMEOUT, Duration.ofMinutes(4))) > 0) {
if (thrownErrorOnTimeout.compareAndSet(false, true)) {
RecipeTimeoutException t = new RecipeTimeoutException(recipe);
- ctx.getOnError().accept(t);
+ ctx.getOnError().accept(wrapForAttribution(recipeStack, src.getSourcePath().toString(), t));
ctx.getOnTimeout().accept(t, ctx);
}
return src;
@@ -382,7 +383,7 @@ void clear() {
if (isInBatch) {
batch.clear();
}
- after = handleError(recipe, src, after, t);
+ after = handleError(recipeStack, src, after, t);
}
if (after != null && after != src) {
after = addRecipesThatMadeChanges(recipeStack, after);
@@ -417,7 +418,7 @@ void clear() {
if (!(t instanceof RecipeRunException)) {
source = Markup.error(source, t);
}
- source = handleError(batch.recipeStacks.get(0).peek(), originalBefore, source, t);
+ source = handleError(batch.recipeStacks.get(0), originalBefore, source, t);
if (source != null && source != beforeError) {
source = addRecipesThatMadeChanges(batch.recipeStacks.get(0), source);
}
@@ -720,9 +721,9 @@ private void recordSourceFileResult(@Nullable String beforePath, @Nullable Strin
recordSourceFileResult(beforePath, afterPath, recipeStack.subList(0, recipeStack.size() - 1), ctx);
}
- private @Nullable SourceFile handleError(Recipe recipe, SourceFile sourceFile, @Nullable SourceFile after,
+ private @Nullable SourceFile handleError(List recipeStack, SourceFile sourceFile, @Nullable SourceFile after,
Throwable t) {
- ctx.getOnError().accept(t);
+ ctx.getOnError().accept(wrapForAttribution(recipeStack, sourceFile.getSourcePath().toString(), t));
if (t instanceof RecipeRunException && after != null) {
try {
@@ -738,13 +739,17 @@ private void recordSourceFileResult(@Nullable String beforePath, @Nullable Strin
// This is so the error is associated with the original source file, and its original source path.
errorsTable.insertRow(ctx, new SourcesFileErrors.Row(
sourceFile.getSourcePath().toString(),
- recipe.getName(),
+ recipeStack.get(recipeStack.size() - 1).getName(),
ExceptionUtils.sanitizeStackTrace(t, RecipeScheduler.class)
));
return after;
}
+ private static RecipeError wrapForAttribution(List recipeStack, @Nullable String sourcePath, Throwable t) {
+ return new RecipeError(recipeStack.stream().map(Recipe::getName).collect(toList()), sourcePath, t);
+ }
+
private static S addRecipesThatMadeChanges(List recipeStack, S afterFile) {
return afterFile.withMarkers(afterFile.getMarkers().computeByType(
RecipesThatMadeChanges.create(recipeStack),
diff --git a/rewrite-core/src/test/java/org/openrewrite/RecipeSchedulerTest.java b/rewrite-core/src/test/java/org/openrewrite/RecipeSchedulerTest.java
index ff13bafee8..18a12bf1e1 100644
--- a/rewrite-core/src/test/java/org/openrewrite/RecipeSchedulerTest.java
+++ b/rewrite-core/src/test/java/org/openrewrite/RecipeSchedulerTest.java
@@ -83,6 +83,79 @@ void exceptionsCauseResult() {
);
}
+ @Test
+ void onErrorReceivesRecipeErrorWithLeafRecipeAndSourcePath() {
+ List captured = new java.util.ArrayList<>();
+ InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> {
+ if (t instanceof RecipeError) {
+ captured.add((RecipeError) t);
+ }
+ });
+ rewriteRun(
+ spec -> spec.executionContext(ctx).recipe(new BoomRecipe()),
+ text("hello", "~~(boom)~~>hello")
+ );
+ assertThat(captured).isNotEmpty().allSatisfy(err -> {
+ assertThat(err.getRecipeStack()).containsExactly("org.openrewrite.BoomRecipe");
+ assertThat(err.getSourcePath()).isEqualTo("file.txt");
+ // Cause is RecipeRunException (the visitor's throw site wrapper) which in turn wraps BoomException.
+ assertThat(err.getCause()).isInstanceOf(RecipeRunException.class);
+ assertThat(err.getCause().getCause()).isInstanceOf(BoomException.class);
+ });
+ }
+
+ @Test
+ void onErrorReceivesRecipeErrorWithFullStackForNestedRecipe() {
+ List captured = new java.util.ArrayList<>();
+ InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> {
+ if (t instanceof RecipeError) {
+ captured.add((RecipeError) t);
+ }
+ });
+ var parent = new DeclarativeRecipe(
+ "io.example.parent",
+ "Parent recipe",
+ "Parent recipe wrapping a boom child.",
+ emptySet(),
+ null,
+ URI.create("dummy:recipe.yml"),
+ false,
+ emptyList()
+ );
+ parent.addUninitialized(new BoomRecipe());
+ parent.initialize(emptyList());
+ rewriteRun(
+ spec -> spec.executionContext(ctx).recipe(parent),
+ text("hello", "~~(boom)~~>hello")
+ );
+ assertThat(captured).isNotEmpty().allSatisfy(err -> {
+ assertThat(err.getRecipeStack())
+ .containsExactly("io.example.parent", "org.openrewrite.BoomRecipe");
+ assertThat(err.getRecipeName()).isEqualTo("org.openrewrite.BoomRecipe");
+ assertThat(err.getSourcePath()).isEqualTo("file.txt");
+ });
+ }
+
+ @Test
+ void onErrorWrapsErrorsThrownDuringGenerate() {
+ List captured = new java.util.ArrayList<>();
+ InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> {
+ if (t instanceof RecipeError) {
+ captured.add((RecipeError) t);
+ }
+ });
+ rewriteRun(
+ spec -> spec.executionContext(ctx).recipe(new BoomGenerateRecipe(false))
+ );
+ assertThat(captured)
+ .singleElement()
+ .satisfies(err -> {
+ assertThat(err.getRecipeStack()).containsExactly("org.openrewrite.BoomGenerateRecipe");
+ assertThat(err.getSourcePath()).isEqualTo("error during generation");
+ assertThat(err.getCause()).isInstanceOf(BoomException.class);
+ });
+ }
+
@Test
void exceptionDuringGenerate() {
rewriteRun(
diff --git a/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java b/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java
index e68ec8542b..c40881c263 100644
--- a/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java
+++ b/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java
@@ -704,10 +704,11 @@ default void rewriteRun(SourceSpec>... sources) {
default ExecutionContext defaultExecutionContext(SourceSpec>[] sourceSpecs) {
InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> {
- if (t instanceof RecipeRunException) {
- fail("Failed to run recipe at " + ((RecipeRunException) t).getCursor(), t);
+ Throwable underlying = t instanceof RecipeError ? t.getCause() : t;
+ if (underlying instanceof RecipeRunException) {
+ fail("Failed to run recipe at " + ((RecipeRunException) underlying).getCursor(), underlying);
}
- fail("Failed to parse sources or run recipe", t);
+ fail("Failed to parse sources or run recipe", underlying);
});
for (Consumer customizer : defaultExecutionContextCustomizers.values()) {
customizer.accept(ctx);