Skip to content

Latest commit

 

History

History
132 lines (98 loc) · 5.47 KB

File metadata and controls

132 lines (98 loc) · 5.47 KB

idempotent-core

Maven Central License: MIT Java Spring Boot

The engine behind every backend: one state machine, one store contract, one serialization pipeline. Whether you annotate a controller, call IdempotentService from a job, or plug in your own database — the guarantees are identical.

Two paths, same guarantees

@Idempotent annotation IdempotentService (programmatic)
Use when Spring-managed methods (controllers, services, @Scheduled) Code outside AOP — message consumers, batch jobs, libraries
Requires spring-boot-starter-aop Nothing extra
Key source HTTP header (wins) or SpEL Explicit String you provide
Return type Inferred from method signature Explicit Class<T> (preferred) or Object.class
TTL duration = "5m" | "PT5M" | "100ms" Duration parameter

You can mix both in the same app — they share the same IdempotentStore.

Install

Pulled in transitively by every storage module. Add directly if you implement your own store:

<dependency>
	<groupId>io.github.arun0009</groupId>
	<artifactId>idempotent-core</artifactId>
	<version>${idempotent.version}</version>
</dependency>

With no storage module on the classpath, InMemoryIdempotentStore is auto-configured — ideal for tests and local development.

@Idempotent

@Idempotent(key = "#orderId", duration = "5m", hashKey = false)
public Order fulfill(String orderId) { ... }
Attribute Default Meaning
key "" SpEL expression. Combined with HTTP X-Idempotency-Key when present — header wins.
duration "PT5M" Entry TTL. Accepts ISO-8601 (PT5M) or Spring short form (5m, 100ms).
hashKey false Store SHA-256 of the key (handy for large request bodies or PII).

Empty key? The method runs without idempotency and the library logs a warning once per method — so misconfiguration is impossible to miss in production logs.

IdempotentService

// Same key + different process name = independent entries
Order order = idempotentService.execute(
		"order-789",        // key
		"fulfill-order",    // process scope
		Order.class,        // explicit return type (preferred)
		() -> fulfillment.run("order-789"),
		Duration.ofMinutes(30));

Other overloads exist — execute(key, supplier, ttl), execute(key, processName, supplier, ttl), untyped variants — see the Javadoc.

What happens when…

Situation Behavior
Operation succeeds (incl. null / void) Cached as COMPLETED for the TTL
Operation throws In-progress entry removed; exception propagates unchanged
Operation returns non-2xx ResponseEntity Entry removed; client can retry
Wait budget exhausted IdempotentWaitExhaustedException
Two callers race the strict insert Loser sees IdempotentKeyConflictException, re-fetches, joins the existing flow

IdempotentException and IdempotentWaitExhaustedException are library-only. Your domain exceptions stay yours.

Configuration

Property Default Description
idempotent.key.header X-Idempotency-Key HTTP header consulted by the aspect
idempotent.inprogress.max.retries 5 Polls while another caller holds IN_PROGRESS
idempotent.inprogress.retry.initial.interval PT0.1S Initial backoff (100ms, PT0.1S, …)
idempotent.inprogress.retry.multiplier 2 Exponential multiplier

Payload serialization

All persistent stores route responses through one IdempotentPayloadCodec.

Property Default Description
idempotent.serialization.strategy json json (Jackson) or java (requires Serializable)

json enables permissive polymorphic typing by default — convenient for Object.class reads, with a startup warning. Lock it down with a customizer bean:

@Bean
IdempotentJsonMapperCustomizer customizer() {
	var ptv = BasicPolymorphicTypeValidator.builder()
			.allowIfBaseType("com.myapp.")
			.build();
	return builder -> builder.polymorphicTypeValidator(ptv);
}

Or replace serialization entirely with your own IdempotentPayloadCodec bean.

Custom IdempotentStore

Implement loadValue, store, update, and remove. getValue is a default on IdempotentStore (expiry + lazy delete over loadValue) — callers use it; do not override it in custom stores.

Method Contract
getValue(key, type) null if absent or expired (default)
loadValue(key, type) null if absent
store(key, value) Strict insert; IdempotentKeyConflictException if the key exists
update(key, value) No-op if absent
remove(key) Idempotent delete
@Bean
@Primary
IdempotentStore myStore() {
	return new MyIdempotentStore();
}

Back to the project overview.