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:
+8
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.idempotency;
|
||||||
|
|
||||||
|
/** HTTP 409 — параллельный запрос с тем же ключом ещё обрабатывается. */
|
||||||
|
public class IdempotencyInProgressException extends RuntimeException {
|
||||||
|
public IdempotencyInProgressException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user