Production bug report: "Why does the webhook error say 'Executing an update/delete query'?"
That's a JPA internal error. It should never reach users. But there it was, stored in the database and visible in the admin panel. Here's how an innocent-looking @Async method broke everything.
The Code That Looked Fine
@Slf4j
@Service
@RequiredArgsConstructor
public class WebhookService {
private final WebhookRepository webhookRepository;
private final WebhookEventRepository webhookEventRepository;
@Async
public void fireEvent(Long userId, String event, Map<String, Object> payload) {
List<WebhookEntity> webhooks = webhookRepository
.findByUserIdAndEnabledTrue(userId);
for (WebhookEntity webhook : webhooks) {
WebhookEventEntity webhookEvent = createWebhookEvent(webhook, event, payload);
deliverWebhook(webhookEvent, webhook);
}
}
@Transactional
public void deliverWebhook(WebhookEventEntity event, WebhookEntity webhook) {
// ... send HTTP request ...
if (success) {
event.setStatus(WebhookEventStatus.DELIVERED);
webhookEventRepository.save(event);
// Reset failure count atomically
webhookRepository.resetFailureCount(webhook.getId(), System.currentTimeMillis());
}
}
}
The repository method:
@Modifying
@Query("UPDATE WebhookEntity w SET w.failureCount = 0, w.modifiedTime = :now WHERE w.id = :id")
void resetFailureCount(@Param("id") Long id, @Param("now") Long now);
Looks reasonable, right? @Async for background processing, @Transactional for database consistency, @Modifying for atomic updates.
The Error
javax.persistence.TransactionRequiredException:
Executing an update/delete query
This was getting stored in the lastError field of webhook events. Users could see it. Security and UX nightmare.
The Root Cause: Self-Invocation Bypasses Proxies
Spring's @Transactional works through proxies. When you call a @Transactional method, you're actually calling a proxy that:
- Starts a transaction
- Calls your actual method
- Commits or rolls back
But here's the catch: self-invocation bypasses the proxy.
@Async
public void fireEvent(...) {
// This runs in a new thread, outside any transaction
for (WebhookEntity webhook : webhooks) {
// This calls the method DIRECTLY, not through the proxy
deliverWebhook(webhookEvent, webhook); // @Transactional is ignored!
}
}
When fireEvent() calls deliverWebhook(), it's calling this.deliverWebhook() - the actual method on the instance, not the Spring proxy. The @Transactional annotation is invisible.
The @Modifying query requires an active transaction. No transaction = exception.
The Fix: TransactionTemplate
When you can't rely on @Transactional, use programmatic transaction management:
@Slf4j
@Service
@RequiredArgsConstructor
public class WebhookService {
private final WebhookRepository webhookRepository;
private final WebhookEventRepository webhookEventRepository;
private final TransactionTemplate transactionTemplate; // Inject this
@Async
public void fireEvent(Long userId, String event, Map<String, Object> payload) {
List<WebhookEntity> webhooks = webhookRepository.findByUserIdAndEnabledTrue(userId);
for (WebhookEntity webhook : webhooks) {
WebhookEventEntity webhookEvent = createWebhookEvent(webhook, event, payload);
deliverWebhook(webhookEvent, webhook);
}
}
public void deliverWebhook(WebhookEventEntity event, WebhookEntity webhook) {
// ... send HTTP request ...
if (success) {
// Wrap database operations in explicit transaction
transactionTemplate.executeWithoutResult(status -> {
event.setStatus(WebhookEventStatus.DELIVERED);
webhookEventRepository.save(event);
webhookRepository.resetFailureCount(webhook.getId(), System.currentTimeMillis());
});
}
}
}
TransactionTemplate doesn't rely on proxies. It explicitly starts and commits transactions. Works everywhere, including:
- Self-invoked methods
@Asyncmethods- Lambda callbacks
- Anywhere proxy magic fails
Bonus: Sanitize Your Error Messages
Even after fixing the transaction issue, you should never leak internal errors to users:
private String sanitizeErrorMessage(String message) {
if (message == null) {
return "Unknown error";
}
// Detect internal errors
if (message.contains("Executing an update/delete query") ||
message.contains("javax.persistence") ||
message.contains("org.hibernate") ||
message.contains("java.sql") ||
message.contains("SQLException")) {
log.error("Internal error during webhook delivery: {}", message);
return "Internal server error";
}
return message.length() > 500 ? message.substring(0, 497) + "..." : message;
}
The Debugging Checklist
When you see TransactionRequiredException:
- Is the method called from the same class? (self-invocation)
- Is the caller
@Async? (new thread, no transaction) - Is the method private? (proxies can't intercept)
- Is the class final? (no proxy possible)
Any of these = @Transactional won't work. Use TransactionTemplate.
Ever been bitten by Spring proxy magic? What's your go-to solution for transaction issues in async code?
Building jo4.io - a URL shortener that actually handles webhooks reliably.