Retry e Resiliência
Toda EXECUTABLE_TASK executada pelo motor pode falhar — uma exceção lançada dentro de um TaskHandler, uma
falha transitória de rede em uma integração externa, um bug. O Kikwiflow trata esse caminho como um cidadão de
primeira classe do modelo de execução, não como uma exceção não tratada: toda falha passa por
io.kikwiflow.execution.FailureHandler, que decide entre reagendar uma nova tentativa ou abrir um
incidente.
O ciclo de vida de uma falha
// Sobrecarga de conveniência; delega para a versão de 4 argumentos com listas vazias.
public void handleFailure(ExecutableTask task, Exception exception) { /* ... */ }
public void handleFailure(ExecutableTask task, Exception exception,
List<OutboxEventEntity> criticalEvents, String tenantId) {
RetryPolicyEvaluator.RetryEvaluationResult evaluation = policyEvaluator.evaluate(task, exception, task.retryPolicy());
Throwable rootCause = exception.getCause() != null ? exception.getCause() : exception;
boolean isUnhandledBusinessError = rootCause instanceof ProcessErrorException;
if (!evaluation.shouldCreateIncident() && !isUnhandledBusinessError) {
// reagenda: status PENDING, retries = retriesLeft, executions++, dueDate = próxima tentativa, error = mensagem
// criticalEventRecorder.recordRetryScheduled(...) -> anexa um RETRY_SCHEDULED à lista de eventos
} else {
// esgotou tentativas (ou é erro fatal/de negócio não tratado): status ERROR + cria Incident
// criticalEventRecorder.recordIncidentCreated(...) -> anexa um INCIDENT_CREATED à lista de eventos
}
// tarefas atualizadas + incidentes + eventos críticos vão num único UnitOfWork -> repository.commitWork(uow)
}
Os parâmetros criticalEvents/tenantId existem para que os eventos de outbox já construídos durante a
execução que falhou (ex.: o FLOW_NODE_FINISHED(ERROR) do próprio nó) e os que a falha gera agora
(RETRY_SCHEDULED ou INCIDENT_CREATED, via CriticalEventRecorder) sejam persistidos na mesma transação
que atualiza a ExecutableTask e cria o Incident — nada de evento perdido só porque o nó terminou em
exceção. Esses eventos só são de fato gravados se o outbox estiver habilitado (ver
Eventos e Observabilidade); ExecutableTask não carrega tenantId, então
quem chama (que já tem o ProcessInstance) o repassa para INCIDENT_CREATED/RETRY_SCHEDULED não ficarem sem
tenant.
Toda a decisão de quando tentar de novo é delegada à interface RetryPolicyEvaluator — uma SPI substituível,
assim como TaskHandler e AnswerProvider:
public interface RetryPolicyEvaluator {
RetryEvaluationResult evaluate(ExecutableTask task, Exception exception, RetryPolicy policy);
record RetryEvaluationResult(long retriesLeft, Instant nextDueDate, boolean shouldCreateIncident) {}
}
A implementação padrão, DefaultRetryPolicyEvaluator, é registrada automaticamente pelo
kikwi-spring-boot-autoconfigure com @ConditionalOnMissingBean — ou seja, você pode fornecer seu próprio
bean RetryPolicyEvaluator para, por exemplo, integrar com um sistema de alertas antes de decidir se cria um
incidente, sem tocar em código do motor.
RetryPolicy: a política declarada no nó
public record RetryPolicy(
RetryStrategy strategy,
int maxRetries,
String initialInterval,
Double multiplier,
String maxInterval,
List<String> intervals
) {}
public enum RetryStrategy {
LINEAR,
EXPONENTIAL_BACKOFF
}
RetryPolicy é opcional em ExecutableTaskDefinition. Se ausente, o motor recorre ao fallback global
(kikwiflow.retry.default-retry-interval, um intervalo fixo aplicado a toda tentativa, sem crescimento).
EXPONENTIAL_BACKOFF
private Instant calculateExponential(RetryPolicy policy, int attemptIndex) {
Duration initial = Duration.parse(policy.initialInterval());
double multiplier = policy.multiplier() != null ? policy.multiplier() : 2.0;
long calculatedMillis = (long) (initial.toMillis() * Math.pow(multiplier, attemptIndex));
Duration calculatedDuration = Duration.ofMillis(calculatedMillis);
if (policy.maxInterval() != null) {
Duration max = Duration.parse(policy.maxInterval());
if (calculatedDuration.compareTo(max) > 0) calculatedDuration = max;
}
return Instant.now().plus(calculatedDuration);
}
{
"retryPolicy": {
"strategy": "EXPONENTIAL_BACKOFF",
"maxRetries": 5,
"initialInterval": "PT10S",
"multiplier": 2.0,
"maxInterval": "PT10M"
}
}
Com essa política: 10s, 20s, 40s, 80s, 160s (limitado a 600s pelo maxInterval) — o cálculo usa
attemptIndex = task.executions() (o número de execuções já realizadas), então o crescimento é determinístico
e não depende de estado externo.
LINEAR
private Instant calculateLinear(RetryPolicy policy, int attemptIndex) {
if (policy.intervals() == null || policy.intervals().isEmpty()) {
return Instant.now().plus(Duration.ofMinutes(1)); // fallback de 1 minuto
}
int index = Math.min(attemptIndex, policy.intervals().size() - 1);
return Instant.now().plus(Duration.parse(policy.intervals().get(index)));
}
{
"retryPolicy": {
"strategy": "LINEAR",
"maxRetries": 3,
"intervals": ["PT5S", "PT30S", "PT2M"]
}
}
A lista intervals é indexada pelo número da tentativa; se houver mais tentativas do que intervalos
declarados, o último intervalo da lista é reutilizado indefinidamente (Math.min(attemptIndex, size - 1)).
Exceções fatais: pulando retry inteiramente
kikwiflow:
retry:
fatal-exceptions:
- java.lang.NullPointerException
- java.lang.IllegalArgumentException
Qualquer exceção cujo nome de classe totalmente qualificado esteja nessa lista pula retry
completamente e abre um incidente na primeira ocorrência, independentemente de maxRetries:
if (fatalExceptionsSet.contains(rootCause.getClass().getName())) {
return new RetryEvaluationResult(0L, Instant.now(), true);
}
:::tip Quando usar fatal-exceptions
Reserve essa lista para classes de erro que nunca se resolvem sozinhas com uma nova tentativa — erros de
programação (NullPointerException, IllegalArgumentException) ou violações de contrato de dados. Retentar
automaticamente esse tipo de falha só atrasa a visibilidade do problema real; é preferível abrir o incidente
imediatamente e notificar um humano.
:::
Erros de negócio não tratados também abrem incidente
Se um TaskHandler lançar ProcessErrorException e nenhum BOUNDARY_ERROR_HANDLER casar com o
errorCode (ver Tratamento de Erros de Negócio), o FailureHandler trata isso
como isUnhandledBusinessError = true e força a criação de um incidente, mesmo que a política de retry ainda
tivesse tentativas disponíveis — um erro de negócio explícito não é uma falha transitória, e insistir em
reexecutar o mesmo TaskHandler não vai mudar o resultado.
Incidentes
public record Incident(
String id, String type, String message, String stackTrace,
String processDefinitionId, String processInstanceId, String executionId,
Instant createdAt, IncidentStatus status, String taskDefinitionId) {}
public enum IncidentStatus { OPEN, RESOLVED }
type é "FAILED_JOB" para esgotamento de tentativas normais, ou "UNHANDLED_BUSINESS_ERROR" para
ProcessErrorException sem handler correspondente. stackTrace é capturado integralmente
(FailureHandler.getStackTrace), então o operacional tem contexto completo sem precisar correlacionar logs.
Retentando um incidente manualmente
KikwiflowEngine.retryIncident(incidentId, identityContext) — exposto via
PUT /incidents/{id}/retry no kikwi-management-rest — reativa a tarefa associada de forma atômica: a
ExecutableTask volta a PENDING com dueDate = now e o incidente muda para RESOLVED, ambos no mesmo
UnitOfWork transacional. Isso é o mecanismo de "hot-fix operacional": corrigir a causa raiz (deploy de um
patch, correção de dado) e retentar sem precisar reiniciar o processo do zero.
public void retryIncident(String incidentId, IdentityContext identityContext) {
Incident incident = kikwiEngineRepository.findIncidentById(incidentId).orElseThrow(/* ... */);
if (incident.status() != IncidentStatus.OPEN) throw new IllegalStateException("Only OPEN incidents can be retried.");
long retriesToRestore = failedTask.retryPolicy() != null
? failedTask.retryPolicy().maxRetries()
: kikwiflowConfig.getDefaultMaxRetries();
// ExecutableTask.toBuilder().status(PENDING).retries(retriesToRestore).error(null).dueDate(now()).executorId(null).build()
// Incident com status RESOLVED
// ambos no mesmo UnitOfWork -> commitWork()
}
retryIncident reseta retries para RetryPolicy.maxRetries() do nó original (a mesma política já copiada em
ExecutableTask.retryPolicy() no momento em que a tarefa foi criada), com fallback para
KikwiflowConfig.defaultMaxRetries (valor fixo 3) quando o nó não declara retryPolicy — o mesmo fallback
usado em ContinuationService ao calcular o orçamento inicial de retries de uma tarefa nova. Ou seja, retentar
manualmente um incidente devolve à tarefa o mesmo orçamento de tentativas que ela teria se estivesse começando
agora, respeitando a política declarada no processo em vez de um valor arbitrário.
:::note defaultMaxRetries ainda não tem chave kikwiflow.*
KikwiflowConfig.defaultMaxRetries é hoje um valor de fábrica hard-coded (3) — ao contrário de
default-retry-interval e fatal-exceptions, não há uma propriedade kikwiflow.retry.default-max-retries
que o KikwiflowAutoConfiguration leia para sobrescrevê-lo. Para mudar esse teto, ou declare um retryPolicy
explícito no nó, ou forneça seu próprio bean KikwiflowConfig. Mesma observação em
Incidentes (API REST).
:::
Ajuste cirúrgico com overrideTaskRetryContext
Para cenários operacionais mais finos — mudar o dueDate de uma tarefa travada, injetar uma RetryPolicy
diferente da declarada no processo, sem esperar o próximo ciclo de falha —
KikwiflowEngine.overrideTaskRetryContext(executableTaskId, customDueDate, newRetriesCount, customRetryPolicy, identityContext) permite sobrescrever qualquer combinação desses campos em uma única chamada transacional,
sempre reiniciando a tarefa com status: PENDING e limpando error/executorId.
Tabela de referência rápida
| Configuração | Onde | Efeito |
|---|---|---|
ExecutableTaskDefinition.retryPolicy | JSON do processo, por nó | Sobrescreve o fallback global para aquele nó específico. |
kikwiflow.retry.default-retry-interval | application.yml | Intervalo fixo usado quando o nó não declara retryPolicy. |
kikwiflow.retry.fatal-exceptions | application.yml | Lista de FQCNs que pulam retry e abrem incidente na 1ª falha. |
kikwiflow.execution.lock-timeout-millis | application.yml | Ver Workers e Tarefas Externas — não é retry de negócio, é liberação de lock de um worker morto. |