Skip to content

Commit d7f1ff5

Browse files
Wrap recipe errors with RecipeError to attribute failures to a nested recipe
The `Consumer<Throwable>` returned by `ExecutionContext#getOnError()` previously received the raw throwable from a failing recipe, leaving downstream tooling no way to tell which nested recipe (in a large declarative recipe with hundreds or thousands of children) actually produced the failure. Introduce `RecipeError` — a `RuntimeException` carrying the recipe path (root → leaf) and source path — and wrap throwables at the single chokepoint `RecipeRunCycle.handleError` and the run-timeout site before invoking the `onError` consumer. The `SourcesFileErrors` data table continues to receive the original sanitized stack trace, so attribution there is unchanged. Update `RewriteTest`'s default consumer to unwrap the new `RecipeError` before its `instanceof RecipeRunException` check.
1 parent ea81d80 commit d7f1ff5

4 files changed

Lines changed: 154 additions & 12 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite;
17+
18+
import lombok.Getter;
19+
import org.jspecify.annotations.Nullable;
20+
21+
import java.util.Collections;
22+
import java.util.List;
23+
24+
/**
25+
* Wraps an exception thrown by a recipe so that {@link ExecutionContext#getOnError()}
26+
* consumers can attribute the failure to the specific nested recipe and source file
27+
* that produced it. This is especially useful for large declarative recipes (e.g.
28+
* thousands of nested recipes) where the underlying stack trace alone does not
29+
* reveal which recipe ran into trouble.
30+
*/
31+
@Getter
32+
public class RecipeError extends RuntimeException {
33+
34+
/**
35+
* The names of the recipes from root to leaf at the time of failure.
36+
* The last element is the recipe that directly threw.
37+
*/
38+
private final List<String> recipeStack;
39+
40+
/**
41+
* The path of the source file being processed when the error occurred,
42+
* or {@code null} when the error was raised outside of a per-source context
43+
* (for example, during scanning recipe generation).
44+
*/
45+
private final @Nullable String sourcePath;
46+
47+
public RecipeError(List<String> recipeStack, @Nullable String sourcePath, Throwable cause) {
48+
super(cause);
49+
this.recipeStack = Collections.unmodifiableList(recipeStack);
50+
this.sourcePath = sourcePath;
51+
}
52+
53+
/**
54+
* @return the leaf recipe name (the recipe that directly threw), or
55+
* {@code "unknown"} if the recipe stack is empty.
56+
*/
57+
public String getRecipeName() {
58+
return recipeStack.isEmpty() ? "unknown" : recipeStack.get(recipeStack.size() - 1);
59+
}
60+
}

rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ public LSS scanSources(LSS sourceSet) {
151151
return source;
152152
});
153153
} catch (Throwable t) {
154-
after = handleError(recipe, source, after, t);
154+
after = handleError(recipeStack, source, after, t);
155155
// We don't normally consider anything the scanning phase does to be a change
156156
// But this simplifies error reporting so that exceptions can all be handled the same
157157
assert after != null;
@@ -183,7 +183,7 @@ private void flushScanBatch(BatchState batch, SourceFile source) {
183183
batch.rpc.batchVisit(source, ctx, rootCursor, batch.items);
184184
} catch (Throwable t) {
185185
if (!batch.recipeStacks.isEmpty()) {
186-
handleError(batch.recipeStacks.get(0).peek(), source, source, t);
186+
handleError(batch.recipeStacks.get(0), source, source, t);
187187
}
188188
}
189189

@@ -211,7 +211,7 @@ public LSS generateSources(LSS sourceSet) {
211211
return source;
212212
});
213213
} catch (Throwable t) {
214-
handleError(recipe, source, source, t);
214+
handleError(recipeStack, source, source, t);
215215
}
216216
}
217217
}
@@ -243,7 +243,7 @@ public LSS generateSources(LSS sourceSet) {
243243
madeChangesInThisCycle.add(recipe);
244244
}
245245
} catch (Throwable t) {
246-
handleError(recipe, new Quark(Tree.randomId(), Paths.get("error during generation"), Markers.EMPTY, null, null), null, t);
246+
handleError(recipeStack, new Quark(Tree.randomId(), Paths.get("error during generation"), Markers.EMPTY, null, null), null, t);
247247
}
248248
}
249249
return acc;
@@ -316,7 +316,7 @@ void clear() {
316316
if (duration.compareTo(ctx.getMessage(ExecutionContext.RUN_TIMEOUT, Duration.ofMinutes(4))) > 0) {
317317
if (thrownErrorOnTimeout.compareAndSet(false, true)) {
318318
RecipeTimeoutException t = new RecipeTimeoutException(recipe);
319-
ctx.getOnError().accept(t);
319+
ctx.getOnError().accept(wrapForAttribution(recipeStack, src.getSourcePath().toString(), t));
320320
ctx.getOnTimeout().accept(t, ctx);
321321
}
322322
return src;
@@ -382,7 +382,7 @@ void clear() {
382382
if (isInBatch) {
383383
batch.clear();
384384
}
385-
after = handleError(recipe, src, after, t);
385+
after = handleError(recipeStack, src, after, t);
386386
}
387387
if (after != null && after != src) {
388388
after = addRecipesThatMadeChanges(recipeStack, after);
@@ -417,7 +417,7 @@ void clear() {
417417
if (!(t instanceof RecipeRunException)) {
418418
source = Markup.error(source, t);
419419
}
420-
source = handleError(batch.recipeStacks.get(0).peek(), originalBefore, source, t);
420+
source = handleError(batch.recipeStacks.get(0), originalBefore, source, t);
421421
if (source != null && source != beforeError) {
422422
source = addRecipesThatMadeChanges(batch.recipeStacks.get(0), source);
423423
}
@@ -720,9 +720,9 @@ private void recordSourceFileResult(@Nullable String beforePath, @Nullable Strin
720720
recordSourceFileResult(beforePath, afterPath, recipeStack.subList(0, recipeStack.size() - 1), ctx);
721721
}
722722

723-
private @Nullable SourceFile handleError(Recipe recipe, SourceFile sourceFile, @Nullable SourceFile after,
723+
private @Nullable SourceFile handleError(List<Recipe> recipeStack, SourceFile sourceFile, @Nullable SourceFile after,
724724
Throwable t) {
725-
ctx.getOnError().accept(t);
725+
ctx.getOnError().accept(wrapForAttribution(recipeStack, sourceFile.getSourcePath().toString(), t));
726726

727727
if (t instanceof RecipeRunException && after != null) {
728728
try {
@@ -738,13 +738,21 @@ private void recordSourceFileResult(@Nullable String beforePath, @Nullable Strin
738738
// This is so the error is associated with the original source file, and its original source path.
739739
errorsTable.insertRow(ctx, new SourcesFileErrors.Row(
740740
sourceFile.getSourcePath().toString(),
741-
recipe.getName(),
741+
recipeStack.get(recipeStack.size() - 1).getName(),
742742
ExceptionUtils.sanitizeStackTrace(t, RecipeScheduler.class)
743743
));
744744

745745
return after;
746746
}
747747

748+
private static RecipeError wrapForAttribution(List<Recipe> recipeStack, @Nullable String sourcePath, Throwable t) {
749+
List<String> recipePath = new ArrayList<>(recipeStack.size());
750+
for (Recipe r : recipeStack) {
751+
recipePath.add(r.getName());
752+
}
753+
return new RecipeError(recipePath, sourcePath, t);
754+
}
755+
748756
private static <S extends SourceFile> S addRecipesThatMadeChanges(List<Recipe> recipeStack, S afterFile) {
749757
return afterFile.withMarkers(afterFile.getMarkers().computeByType(
750758
RecipesThatMadeChanges.create(recipeStack),

rewrite-core/src/test/java/org/openrewrite/RecipeSchedulerTest.java

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,79 @@ void exceptionsCauseResult() {
8383
);
8484
}
8585

86+
@Test
87+
void onErrorReceivesRecipeErrorWithLeafRecipeAndSourcePath() {
88+
List<RecipeError> captured = new java.util.ArrayList<>();
89+
InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> {
90+
if (t instanceof RecipeError) {
91+
captured.add((RecipeError) t);
92+
}
93+
});
94+
rewriteRun(
95+
spec -> spec.executionContext(ctx).recipe(new BoomRecipe()),
96+
text("hello", "~~(boom)~~>hello")
97+
);
98+
assertThat(captured).isNotEmpty().allSatisfy(err -> {
99+
assertThat(err.getRecipeStack()).containsExactly("org.openrewrite.BoomRecipe");
100+
assertThat(err.getSourcePath()).isEqualTo("file.txt");
101+
// Cause is RecipeRunException (the visitor's throw site wrapper) which in turn wraps BoomException.
102+
assertThat(err.getCause()).isInstanceOf(RecipeRunException.class);
103+
assertThat(err.getCause().getCause()).isInstanceOf(BoomException.class);
104+
});
105+
}
106+
107+
@Test
108+
void onErrorReceivesRecipeErrorWithFullStackForNestedRecipe() {
109+
List<RecipeError> captured = new java.util.ArrayList<>();
110+
InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> {
111+
if (t instanceof RecipeError) {
112+
captured.add((RecipeError) t);
113+
}
114+
});
115+
var parent = new DeclarativeRecipe(
116+
"io.example.parent",
117+
"Parent recipe",
118+
"Parent recipe wrapping a boom child.",
119+
emptySet(),
120+
null,
121+
URI.create("dummy:recipe.yml"),
122+
false,
123+
emptyList()
124+
);
125+
parent.addUninitialized(new BoomRecipe());
126+
parent.initialize(emptyList());
127+
rewriteRun(
128+
spec -> spec.executionContext(ctx).recipe(parent),
129+
text("hello", "~~(boom)~~>hello")
130+
);
131+
assertThat(captured).isNotEmpty().allSatisfy(err -> {
132+
assertThat(err.getRecipeStack())
133+
.containsExactly("io.example.parent", "org.openrewrite.BoomRecipe");
134+
assertThat(err.getRecipeName()).isEqualTo("org.openrewrite.BoomRecipe");
135+
assertThat(err.getSourcePath()).isEqualTo("file.txt");
136+
});
137+
}
138+
139+
@Test
140+
void onErrorWrapsErrorsThrownDuringGenerate() {
141+
List<RecipeError> captured = new java.util.ArrayList<>();
142+
InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> {
143+
if (t instanceof RecipeError) {
144+
captured.add((RecipeError) t);
145+
}
146+
});
147+
rewriteRun(
148+
spec -> spec.executionContext(ctx).recipe(new BoomGenerateRecipe(false))
149+
);
150+
assertThat(captured)
151+
.singleElement()
152+
.satisfies(err -> {
153+
assertThat(err.getRecipeStack()).containsExactly("org.openrewrite.BoomGenerateRecipe");
154+
assertThat(err.getSourcePath()).isEqualTo("error during generation");
155+
assertThat(err.getCause()).isInstanceOf(BoomException.class);
156+
});
157+
}
158+
86159
@Test
87160
void exceptionDuringGenerate() {
88161
rewriteRun(

rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -704,8 +704,9 @@ default void rewriteRun(SourceSpec<?>... sources) {
704704

705705
default ExecutionContext defaultExecutionContext(SourceSpec<?>[] sourceSpecs) {
706706
InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> {
707-
if (t instanceof RecipeRunException) {
708-
fail("Failed to run recipe at " + ((RecipeRunException) t).getCursor(), t);
707+
Throwable underlying = t instanceof RecipeError ? t.getCause() : t;
708+
if (underlying instanceof RecipeRunException) {
709+
fail("Failed to run recipe at " + ((RecipeRunException) underlying).getCursor(), t);
709710
}
710711
fail("Failed to parse sources or run recipe", t);
711712
});

0 commit comments

Comments
 (0)