Skip to content
Closed
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
60 changes: 60 additions & 0 deletions rewrite-core/src/main/java/org/openrewrite/RecipeError.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2026 the original author or authors.
* <p>
* 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
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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<String> 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<String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -211,7 +212,7 @@ public LSS generateSources(LSS sourceSet) {
return source;
});
} catch (Throwable t) {
handleError(recipe, source, source, t);
handleError(recipeStack, source, source, t);
}
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<Recipe> 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 {
Expand All @@ -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<Recipe> recipeStack, @Nullable String sourcePath, Throwable t) {
return new RecipeError(recipeStack.stream().map(Recipe::getName).collect(toList()), sourcePath, t);
}

private static <S extends SourceFile> S addRecipesThatMadeChanges(List<Recipe> recipeStack, S afterFile) {
return afterFile.withMarkers(afterFile.getMarkers().computeByType(
RecipesThatMadeChanges.create(recipeStack),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,79 @@ void exceptionsCauseResult() {
);
}

@Test
void onErrorReceivesRecipeErrorWithLeafRecipeAndSourcePath() {
List<RecipeError> 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<RecipeError> 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<RecipeError> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExecutionContext> customizer : defaultExecutionContextCustomizers.values()) {
customizer.accept(ctx);
Expand Down
Loading