Skip to content

Commit 9bdff5d

Browse files
committed
fix(clients): add idempotent replay logic and handle malformed UUID
P1: When idempotencyKey is provided, look up existing Request first. Same key+payload returns existing request (idempotent replay). Same key+different payload throws IdempotencyKeyConflictException (409). P2: Added HttpMessageNotReadableException handler in GlobalExceptionHandler to return 400 instead of 500 for malformed UUID in request body. Added i18n messages for both error cases in all three bundles.
1 parent e87d1cf commit 9bdff5d

7 files changed

Lines changed: 102 additions & 0 deletions

File tree

src/main/java/lt/satsyuk/exception/GlobalExceptionHandler.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.springframework.http.HttpStatus;
99
import org.springframework.http.MediaType;
1010
import org.springframework.http.ResponseEntity;
11+
import org.springframework.http.converter.HttpMessageNotReadableException;
1112
import org.springframework.security.access.AccessDeniedException;
1213
import org.springframework.web.bind.MethodArgumentNotValidException;
1314
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -107,6 +108,24 @@ public ResponseEntity<AppResponse<Void>> handlePhoneExists(PhoneAlreadyExistsExc
107108
.body(AppResponse.<Void>error(AppResponse.ErrorCode.CONFLICT.getCode(), message));
108109
}
109110

111+
@ExceptionHandler(IdempotencyKeyConflictException.class)
112+
public ResponseEntity<AppResponse<Void>> handleIdempotencyKeyConflict(IdempotencyKeyConflictException ex) {
113+
String message = messageService.getMessage(ex.getMessageCode(), new Object[]{ex.getIdempotencyKey()});
114+
return ResponseEntity
115+
.status(HttpStatus.CONFLICT)
116+
.contentType(MediaType.APPLICATION_JSON)
117+
.body(AppResponse.<Void>error(AppResponse.ErrorCode.CONFLICT.getCode(), message));
118+
}
119+
120+
@ExceptionHandler(HttpMessageNotReadableException.class)
121+
public ResponseEntity<AppResponse<Void>> handleHttpMessageNotReadable(HttpMessageNotReadableException ex) {
122+
String message = messageService.getMessage("error.request.invalidPayload");
123+
return ResponseEntity
124+
.status(HttpStatus.BAD_REQUEST)
125+
.contentType(MediaType.APPLICATION_JSON)
126+
.body(AppResponse.<Void>error(AppResponse.ErrorCode.BAD_REQUEST.getCode(), message));
127+
}
128+
110129
@ExceptionHandler(Exception.class)
111130
public ResponseEntity<AppResponse<Void>> handleGeneric(Exception ex) {
112131
log.error("Unhandled exception caught by GlobalExceptionHandler", ex);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package lt.satsyuk.exception;
2+
3+
import lombok.Getter;
4+
5+
@Getter
6+
public class IdempotencyKeyConflictException extends RuntimeException {
7+
private final String idempotencyKey;
8+
9+
public IdempotencyKeyConflictException(String idempotencyKey) {
10+
super("Request with idempotency key=" + idempotencyKey + " already exists with different payload");
11+
this.idempotencyKey = idempotencyKey;
12+
}
13+
14+
public String getMessageCode() {
15+
return "error.request.idempotencyKeyConflict";
16+
}
17+
}

src/main/java/lt/satsyuk/service/RequestService.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import lt.satsyuk.dto.CreateClientRequest;
77
import lt.satsyuk.dto.RequestAcceptedResponse;
88
import lt.satsyuk.dto.RequestStatusResponse;
9+
import lt.satsyuk.exception.IdempotencyKeyConflictException;
910
import lt.satsyuk.exception.RequestNotFoundException;
1011
import lt.satsyuk.model.Request;
1112
import lt.satsyuk.model.RequestStatus;
@@ -18,6 +19,7 @@
1819

1920
import java.time.OffsetDateTime;
2021
import java.time.ZoneOffset;
22+
import java.util.Optional;
2123
import java.util.UUID;
2224

2325
@Service
@@ -32,6 +34,18 @@ public class RequestService {
3234

3335
public RequestAcceptedResponse submitClientCreateRequest(CreateClientRequest createClientRequest) {
3436
OffsetDateTime now = now();
37+
38+
if (createClientRequest.idempotencyKey() != null) {
39+
Optional<Request> existing = requestRepository.findById(createClientRequest.idempotencyKey());
40+
if (existing.isPresent()) {
41+
Request request = existing.get();
42+
if (request.getRequestData().equals(writeJson(createClientRequest))) {
43+
return new RequestAcceptedResponse(request.getId(), request.getStatus());
44+
}
45+
throw new IdempotencyKeyConflictException(createClientRequest.idempotencyKey().toString());
46+
}
47+
}
48+
3549
UUID requestId = createClientRequest.idempotencyKey() != null
3650
? createClientRequest.idempotencyKey()
3751
: UUID.randomUUID();

src/main/resources/messages.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ error.account.notFound=Account for client id\u003D{0} not found
2020
error.account.optimisticLock=Too many optimistic lock retries for client id\u003D{0}
2121
error.request.notFound=Request with id\u003D{0} not found
2222
error.request.schedulingFailed=Failed to schedule request processing
23+
error.request.idempotencyKeyConflict=Request with idempotency key\u003D{0} already exists with different payload
24+
error.request.invalidPayload=Invalid request payload
2325
error.validation.failed=Validation failed
2426
error.typeMismatch=Invalid value\: {0}
2527

src/main/resources/messages_en.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ error.account.notFound=Account for client id\u003D{0} not found
2020
error.account.optimisticLock=Too many optimistic lock retries for client id\u003D{0}
2121
error.request.notFound=Request with id\u003D{0} not found
2222
error.request.schedulingFailed=Failed to schedule request processing
23+
error.request.idempotencyKeyConflict=Request with idempotency key\u003D{0} already exists with different payload
24+
error.request.invalidPayload=Invalid request payload
2325
error.validation.failed=Validation failed
2426
error.typeMismatch=Invalid value: {0}
2527

src/main/resources/messages_ru.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ error.account.notFound=Счет для клиента с id={0} не найде
2020
error.account.optimisticLock=Превышено количество повторных попыток optimistic lock для клиента с id={0}
2121
error.request.notFound=Заявка с id={0} не найдена
2222
error.request.schedulingFailed=Не удалось запланировать обработку заявки
23+
error.request.idempotencyKeyConflict=Заявка с idempotency key={0} уже существует с другим телом запроса
24+
error.request.invalidPayload=Некорректное тело запроса
2325
error.validation.failed=Ошибка валидации
2426
error.typeMismatch=Неверное значение: {0}
2527

src/test/java/lt/satsyuk/service/RequestServiceTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import lt.satsyuk.dto.RequestAcceptedResponse;
77
import lt.satsyuk.dto.RequestStatusResponse;
88
import lt.satsyuk.api.util.TestTime;
9+
import lt.satsyuk.exception.IdempotencyKeyConflictException;
910
import lt.satsyuk.model.Request;
1011
import lt.satsyuk.model.RequestStatus;
1112
import lt.satsyuk.model.RequestType;
@@ -27,6 +28,7 @@
2728
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2829
import static org.mockito.ArgumentMatchers.any;
2930
import static org.mockito.Mockito.doThrow;
31+
import static org.mockito.Mockito.never;
3032
import static org.mockito.Mockito.verify;
3133
import static org.mockito.Mockito.when;
3234

@@ -95,6 +97,50 @@ void submitClientCreateRequestUsesIdempotencyKeyAsRequestId() throws Exception {
9597
assertThat(response.requestId()).isEqualTo(idempotencyKey);
9698
}
9799

100+
@Test
101+
void submitClientCreateRequestReturnsExistingRequestForSameIdempotencyKeyAndPayload() throws Exception {
102+
UUID idempotencyKey = UUID.randomUUID();
103+
CreateClientRequest createClientRequest = new CreateClientRequest(idempotencyKey, "John", "Doe", "+37061234567");
104+
OffsetDateTime now = OffsetDateTime.now();
105+
Request existingRequest = Request.builder()
106+
.id(idempotencyKey)
107+
.type(RequestType.CLIENT_CREATE)
108+
.status(RequestStatus.COMPLETED)
109+
.createdAt(now)
110+
.statusChangedAt(now)
111+
.requestData(objectMapper.writeValueAsString(createClientRequest))
112+
.build();
113+
when(requestRepository.findById(idempotencyKey)).thenReturn(Optional.of(existingRequest));
114+
115+
RequestAcceptedResponse response = requestService.submitClientCreateRequest(createClientRequest);
116+
117+
assertThat(response.requestId()).isEqualTo(idempotencyKey);
118+
assertThat(response.status()).isEqualTo(RequestStatus.COMPLETED);
119+
verify(requestRepository, never()).save(any(Request.class));
120+
verify(requestSchedulerService, never()).scheduleClientCreateRequest(any(UUID.class));
121+
}
122+
123+
@Test
124+
void submitClientCreateRequestThrowsConflictForSameIdempotencyKeyDifferentPayload() throws Exception {
125+
UUID idempotencyKey = UUID.randomUUID();
126+
CreateClientRequest incoming = new CreateClientRequest(idempotencyKey, "John", "Doe", "+37061234567");
127+
CreateClientRequest existingPayload = new CreateClientRequest(idempotencyKey, "Jane", "Roe", "+37069999999");
128+
OffsetDateTime now = OffsetDateTime.now();
129+
Request existingRequest = Request.builder()
130+
.id(idempotencyKey)
131+
.type(RequestType.CLIENT_CREATE)
132+
.status(RequestStatus.COMPLETED)
133+
.createdAt(now)
134+
.statusChangedAt(now)
135+
.requestData(objectMapper.writeValueAsString(existingPayload))
136+
.build();
137+
when(requestRepository.findById(idempotencyKey)).thenReturn(Optional.of(existingRequest));
138+
139+
assertThatThrownBy(() -> requestService.submitClientCreateRequest(incoming))
140+
.isInstanceOf(IdempotencyKeyConflictException.class)
141+
.hasMessageContaining(idempotencyKey.toString());
142+
}
143+
98144
@Test
99145
void getRequestStatusReturnsNestedJsonResponse() {
100146
UUID requestId = UUID.randomUUID();

0 commit comments

Comments
 (0)