feat(idempotency): Idempotency-Key filter для exactly-once write API

OncePerRequestFilter перехватывает POST/PUT/PATCH с заголовком
Idempotency-Key. Алгоритм:
- sha-256(body) → request_hash
- key найден с тем же hash → возврат cached response (status + body)
- key с другим hash → 422 idempotency_conflict
- key зарезервирован но без response → 409 idempotency_in_progress
- новый key → reserve, запустить handler, кэшировать 2xx ответ

Race-safety: reserve через PK saveAndFlush, DataIntegrityViolation
поднимается до 409. retention 30 дней (cleanup job — будущая работа).

Filter автоматически регистрируется через @Component и
scanBasePackages в OrdinisApplication.
This commit is contained in:
Александр Зимин
2026-05-04 00:10:08 +03:00
parent 07149cafd8
commit 45e211f0ae
4 changed files with 271 additions and 0 deletions
@@ -0,0 +1,8 @@
package cloud.nstart.terravault.ordinis.restapi.idempotency;
/** HTTP 422 — клиент использует один и тот же Idempotency-Key для разных тел запроса. */
public class IdempotencyConflictException extends RuntimeException {
public IdempotencyConflictException(String message) {
super(message);
}
}
@@ -0,0 +1,138 @@
package cloud.nstart.terravault.ordinis.restapi.idempotency;
import cloud.nstart.terravault.ordinis.domain.idempotency.IdempotencyKey;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
import java.util.Optional;
import java.util.Set;
/**
* Servlet filter для exactly-once semantics на write API.
*
* <p>Срабатывает только на POST/PUT/PATCH (мутирующие методы) с заголовком
* {@code Idempotency-Key}. На GET/HEAD/DELETE/OPTIONS — pass-through.
*
* <p>Логика:
* <ol>
* <li>Считает sha-256 хэш от request body.</li>
* <li>Ищет ключ в БД. Если найден с тем же hash — возвращает cached
* {@code response_status + response_body} без выполнения handler.</li>
* <li>Если ключ есть с другим hash — 422 (конфликт тел запроса).</li>
* <li>Если ключа нет — резервирует строку, передаёт управление вниз, после
* успешного 2xx ответа сохраняет body в БД.</li>
* </ol>
*/
@Component
public class IdempotencyFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(IdempotencyFilter.class);
private static final String HEADER = "Idempotency-Key";
private static final Set<String> MUTATING = Set.of("POST", "PUT", "PATCH");
private final IdempotencyService service;
private final ObjectMapper objectMapper;
public IdempotencyFilter(IdempotencyService service, ObjectMapper objectMapper) {
this.service = service;
this.objectMapper = objectMapper;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
if (!MUTATING.contains(request.getMethod())) {
chain.doFilter(request, response);
return;
}
String key = request.getHeader(HEADER);
if (key == null || key.isBlank()) {
chain.doFilter(request, response);
return;
}
if (key.length() > 128) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
writeError(response, 400, "invalid_idempotency_key",
"Idempotency-Key должен быть короче 128 символов");
return;
}
ContentCachingRequestWrapper wrappedReq = new ContentCachingRequestWrapper(request);
// Need to read body to compute hash; do it after first content read. Spring caches via
// ContentCachingRequestWrapper after handler reads, so we trigger the read manually.
wrappedReq.getInputStream().readAllBytes(); // populate cache
byte[] body = wrappedReq.getContentAsByteArray();
String hash = service.hashRequest(body);
try {
Optional<IdempotencyKey> cached = service.lookup(key, hash);
if (cached.isPresent()) {
IdempotencyKey ik = cached.get();
if (ik.getResponseStatus() == null) {
// зарезервирован, но обработка не завершилась — клиент должен retry
response.setStatus(HttpServletResponse.SC_CONFLICT);
writeError(response, 409, "idempotency_in_progress",
"Запрос с Idempotency-Key '" + key + "' ещё обрабатывается");
return;
}
log.debug("Idempotency hit для ключа '{}' → status={}", key, ik.getResponseStatus());
response.setStatus(ik.getResponseStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
if (ik.getResponseBody() != null) {
byte[] bytes = objectMapper.writeValueAsBytes(ik.getResponseBody());
response.setContentLength(bytes.length);
response.getOutputStream().write(bytes);
}
return;
}
service.reserve(key, hash);
} catch (IdempotencyConflictException e) {
response.setStatus(422);
writeError(response, 422, "idempotency_conflict", e.getMessage());
return;
} catch (IdempotencyInProgressException e) {
response.setStatus(HttpServletResponse.SC_CONFLICT);
writeError(response, 409, "idempotency_in_progress", e.getMessage());
return;
}
ContentCachingResponseWrapper wrappedResp = new ContentCachingResponseWrapper(response);
chain.doFilter(wrappedReq, wrappedResp);
int status = wrappedResp.getStatus();
byte[] respBody = wrappedResp.getContentAsByteArray();
// Кэшируем только успешные ответы; ошибки могут быть транзиентными — клиент retry'ит.
if (status >= 200 && status < 300) {
try {
service.saveResponse(key, status, respBody);
} catch (Exception e) {
log.warn("Не удалось сохранить idempotent response для ключа '{}': {}", key, e.getMessage());
}
}
wrappedResp.copyBodyToResponse();
}
private void writeError(HttpServletResponse response, int status, String code, String message)
throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
String json = "{\"status\":" + status
+ ",\"code\":\"" + code + "\""
+ ",\"message\":\"" + message.replace("\"", "\\\"") + "\"}";
response.getOutputStream().write(json.getBytes());
}
}
@@ -0,0 +1,8 @@
package cloud.nstart.terravault.ordinis.restapi.idempotency;
/** HTTP 409 — параллельный запрос с тем же ключом ещё обрабатывается. */
public class IdempotencyInProgressException extends RuntimeException {
public IdempotencyInProgressException(String message) {
super(message);
}
}
@@ -0,0 +1,117 @@
package cloud.nstart.terravault.ordinis.restapi.idempotency;
import cloud.nstart.terravault.ordinis.domain.idempotency.IdempotencyKey;
import cloud.nstart.terravault.ordinis.domain.idempotency.IdempotencyKeyRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.Optional;
/**
* Хранение и lookup для Idempotency-Key.
*
* <p>Контракт:
* <ul>
* <li>{@link #lookup(String, String)} — возвращает существующий ключ если он уже
* видел тот же request hash. Если hash не совпадает — кидает
* {@link IdempotencyConflictException} (HTTP 422 у клиента).</li>
* <li>{@link #reserve(String, String)} — пытается атомарно зарегистрировать
* новый ключ. Бросает {@link IdempotencyConflictException} если ключ уже
* занят чужим hash (race-safe через unique PK constraint).</li>
* <li>{@link #saveResponse(String, int, JsonNode)} — после успешной обработки
* сохраняет response для повтора.</li>
* </ul>
*/
@Service
public class IdempotencyService {
private static final Logger log = LoggerFactory.getLogger(IdempotencyService.class);
/** Хранилище: 30 дней (соответствует комментарию в migration 0006). */
private static final long RETENTION_DAYS = 30;
private final IdempotencyKeyRepository repository;
private final ObjectMapper objectMapper;
public IdempotencyService(IdempotencyKeyRepository repository, ObjectMapper objectMapper) {
this.repository = repository;
this.objectMapper = objectMapper;
}
/** Sha-256 hex от тела запроса; идентичен только для байт-в-байт идентичных тел. */
public String hashRequest(byte[] body) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(body == null ? new byte[0] : body));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
@Transactional(readOnly = true)
public Optional<IdempotencyKey> lookup(String key, String requestHash) {
Optional<IdempotencyKey> existing = repository.findById(key);
if (existing.isEmpty()) {
return Optional.empty();
}
IdempotencyKey ik = existing.get();
if (!ik.getRequestHash().equals(requestHash)) {
throw new IdempotencyConflictException(
"Idempotency-Key '" + key + "' уже использовался с другим телом запроса");
}
return existing;
}
/**
* Резервирует ключ перед обработкой. Если запись уже создана — поднимает 422
* (клиент видит, что мы только что начали обрабатывать тот же ключ; обычно это
* следствие гонки double-submit, и второй запрос должен retry'ить).
*/
@Transactional
public void reserve(String key, String requestHash) {
OffsetDateTime expires = OffsetDateTime.now().plusDays(RETENTION_DAYS);
IdempotencyKey reserved = new IdempotencyKey(key, requestHash, expires);
try {
repository.saveAndFlush(reserved);
} catch (DataIntegrityViolationException e) {
// Кто-то параллельный успел зарезервировать тот же ключ.
Optional<IdempotencyKey> existing = repository.findById(key);
if (existing.isPresent() && existing.get().getRequestHash().equals(requestHash)) {
// То же тело — клиент может retry'ить, мы вернём 409 чтобы он понял что обработка ещё идёт.
throw new IdempotencyInProgressException(
"Запрос с Idempotency-Key '" + key + "' ещё обрабатывается");
}
throw new IdempotencyConflictException(
"Idempotency-Key '" + key + "' уже использовался с другим телом запроса");
}
}
@Transactional
public void saveResponse(String key, int status, byte[] body) {
IdempotencyKey ik = repository.findById(key).orElse(null);
if (ik == null) {
log.warn("saveResponse для отсутствующего ключа '{}', пропускаем", key);
return;
}
JsonNode parsed = null;
if (body != null && body.length > 0) {
try {
parsed = objectMapper.readTree(body);
} catch (Exception e) {
log.debug("Тело ответа для ключа '{}' не JSON, не кэшируем", key);
}
}
ik.setResponse(status, parsed);
repository.save(ik);
}
}