diff --git a/backend/src/main/java/mouda/backend/notification/domain/FcmFailedResponse.java b/backend/src/main/java/mouda/backend/notification/domain/FcmFailedResponse.java index 0a0f09877..a02d41c3c 100644 --- a/backend/src/main/java/mouda/backend/notification/domain/FcmFailedResponse.java +++ b/backend/src/main/java/mouda/backend/notification/domain/FcmFailedResponse.java @@ -3,113 +3,98 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; import java.util.stream.IntStream; import com.google.firebase.messaging.BatchResponse; -import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.MessagingErrorCode; import com.google.firebase.messaging.SendResponse; import lombok.AccessLevel; import lombok.Getter; import lombok.RequiredArgsConstructor; -import lombok.ToString; +import mouda.backend.notification.util.FcmRetryAfterExtractor; @RequiredArgsConstructor(access = AccessLevel.PRIVATE) @Getter -@ToString public class FcmFailedResponse { - private static final int DEFAULT_RETRY_AFTER_SECONDS = 60; - private final BatchResponse batchResponse; - private final List failedWith429Tokens; - private final List failedWith5xxTokens; - private final List nonRetryableFailedTokens; - + private final Map> failedTokens; - public static FcmFailedResponse from(BatchResponse response, List triedTokens) { + public static FcmFailedResponse from(BatchResponse response, List triedTokens) { + Map> result = new ConcurrentHashMap<>(); List responses = response.getResponses(); - List failedWith429Tokens = new ArrayList<>(); - List failedWith5xxTokens = new ArrayList<>(); - List nonRetryableFailedTokens = new ArrayList<>(); - IntStream.range(0, responses.size()) .forEach(i -> { SendResponse sendResponse = responses.get(i); if (sendResponse.isSuccessful()) { return; } - String token = triedTokens.get(i); - if (isFailedWith429(sendResponse)) { - failedWith429Tokens.add(token); - return; - } - if (isFailedWith5xx(sendResponse)) { - failedWith5xxTokens.add(token); - return; - } - nonRetryableFailedTokens.add(token); + FcmToken token = triedTokens.get(i); + MessagingErrorCode errorCode = sendResponse.getException().getMessagingErrorCode(); + result.computeIfAbsent(errorCode, k -> new ArrayList<>()).add(token); }); - return new FcmFailedResponse(response, failedWith429Tokens, failedWith5xxTokens, nonRetryableFailedTokens); + return new FcmFailedResponse(response, result); } - private static boolean isFailedWith429(SendResponse response) { - return hasSameErrorCode(response, MessagingErrorCode.QUOTA_EXCEEDED); + public List getFailedWith404Tokens() { + return getTokens(this::isFailedWith404); } - private static boolean isFailedWith5xx(SendResponse response) { - return hasSameErrorCode(response, MessagingErrorCode.INTERNAL, MessagingErrorCode.UNAVAILABLE); + public List getFailedWith429Tokens() { + return getTokens(this::isFailedWith429); } - private static boolean hasSameErrorCode(SendResponse response, MessagingErrorCode... errorCodes) { - if (response.isSuccessful()) { - return false; - } - FirebaseMessagingException exception = response.getException(); - return Arrays.stream(errorCodes) - .anyMatch(errorCode -> exception.getMessagingErrorCode() == errorCode); + public List getFailedWith5xxTokens() { + return getTokens(this::isFailedWith5xx); + } + + public List getNonRetryableFailedTokens() { + return getTokens(errorCode -> !isFailedWith429(errorCode) && !isFailedWith5xx(errorCode)); + } + + public List getFinallyFailedTokens() { + return failedTokens.values().stream() + .flatMap(List::stream) + .toList(); + } + + public int getRetryAfterSeconds() { + return FcmRetryAfterExtractor.getRetryAfterSeconds(batchResponse); } public boolean hasNoRetryableTokens() { - return failedWith429Tokens.isEmpty() && failedWith5xxTokens.isEmpty(); + return isTokenAbsent(MessagingErrorCode.QUOTA_EXCEEDED, MessagingErrorCode.INTERNAL, + MessagingErrorCode.UNAVAILABLE); } - public boolean hasFailedWith429Tokens() { - return !failedWith429Tokens.isEmpty(); + private List getTokens(Predicate filter) { + return failedTokens.keySet().stream() + .filter(filter) + .flatMap(errorCode -> failedTokens.get(errorCode).stream()) + .toList(); } - public boolean hasFailedWith5xxTokens() { - return !failedWith5xxTokens.isEmpty(); + private boolean isFailedWith404(MessagingErrorCode errorCode) { + return errorCode == MessagingErrorCode.UNREGISTERED; } - public List getFinallyFailedTokens() { - List failedTokens = new ArrayList<>(); - failedTokens.addAll(failedWith429Tokens); - failedTokens.addAll(failedWith5xxTokens); - failedTokens.addAll(nonRetryableFailedTokens); - return failedTokens; + private boolean isFailedWith429(MessagingErrorCode errorCode) { + return errorCode == MessagingErrorCode.QUOTA_EXCEEDED; } - public int getRetryAfterSeconds() { - List responses = batchResponse.getResponses(); - return responses.stream() - .filter(FcmFailedResponse::isFailedWith429) - .map(this::parseRetryAfterSeconds) - .findAny() - .orElse(DEFAULT_RETRY_AFTER_SECONDS); + private boolean isFailedWith5xx(MessagingErrorCode errorCode) { + return errorCode == MessagingErrorCode.INTERNAL || + errorCode == MessagingErrorCode.UNAVAILABLE; } - private int parseRetryAfterSeconds(SendResponse response) { - Object retryAfterHeader = response.getException().getHttpResponse().getHeaders().get("Retry-After"); - if (retryAfterHeader == null) { - return DEFAULT_RETRY_AFTER_SECONDS; - } - try { - return Integer.parseInt(retryAfterHeader.toString()); - } catch (NumberFormatException e) { - return DEFAULT_RETRY_AFTER_SECONDS; - } + private boolean isTokenAbsent(MessagingErrorCode... errorCodes) { + return Arrays.stream(errorCodes) + .map(failedTokens::get) + .allMatch(tokens -> tokens == null || tokens.isEmpty()); } } diff --git a/backend/src/main/java/mouda/backend/notification/domain/FcmToken.java b/backend/src/main/java/mouda/backend/notification/domain/FcmToken.java new file mode 100644 index 000000000..46f32ee4a --- /dev/null +++ b/backend/src/main/java/mouda/backend/notification/domain/FcmToken.java @@ -0,0 +1,23 @@ +package mouda.backend.notification.domain; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +@Builder +public class FcmToken { + + private final long memberId; + private final long tokenId; + private final String token; + + @Override + public String toString() { + return "FcmToken{" + + "memberId=" + memberId + + ", tokenId=" + tokenId + + '}'; + } +} diff --git a/backend/src/main/java/mouda/backend/notification/implement/fcm/FcmNotificationSender.java b/backend/src/main/java/mouda/backend/notification/implement/fcm/FcmNotificationSender.java index 072b7d5da..21c6b3279 100644 --- a/backend/src/main/java/mouda/backend/notification/implement/fcm/FcmNotificationSender.java +++ b/backend/src/main/java/mouda/backend/notification/implement/fcm/FcmNotificationSender.java @@ -1,9 +1,7 @@ package mouda.backend.notification.implement.fcm; -import java.util.ArrayList; import java.util.List; import java.util.concurrent.Executors; -import java.util.stream.IntStream; import org.springframework.stereotype.Component; @@ -13,29 +11,27 @@ import com.google.firebase.messaging.BatchResponse; import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.MessagingErrorCode; import com.google.firebase.messaging.MulticastMessage; -import com.google.firebase.messaging.SendResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import mouda.backend.notification.domain.CommonNotification; +import mouda.backend.notification.domain.FcmToken; import mouda.backend.notification.domain.Recipient; import mouda.backend.notification.implement.NotificationSender; import mouda.backend.notification.implement.fcm.token.FcmTokenFinder; -import mouda.backend.notification.implement.fcm.token.FcmTokenWriter; @Component @Slf4j @RequiredArgsConstructor public class FcmNotificationSender implements NotificationSender { + private static final int MAX_ATTEMPT = 3; private static final int THREAD_POOL_SIZE_FOR_CALLBACK = 5; private final FcmMessageFactory fcmMessageFactory; - private final FcmTokenFinder fcmTokenFinder; private final FcmResponseHandler fcmResponseHandler; - private final FcmTokenWriter fcmTokenWriter; + private final FcmTokenFinder fcmTokenFinder; @Override public void sendNotification(CommonNotification notification, List recipients) { @@ -48,64 +44,44 @@ private void sendAllMulticastMessage(CommonNotification notification, List sendMulticastMessage(notification, multicastMessage, tokens)); + .forEach(multicastMessage -> sendMulticastMessage(notification, multicastMessage, tokens, attempt)); } - private void sendMulticastMessage(CommonNotification notification, MulticastMessage message, - List initialTokens) { + private void sendMulticastMessage( + CommonNotification notification, MulticastMessage message, List initialTokens, int attempt + ) { + if (attempt > MAX_ATTEMPT) { + List tokens = fcmTokenFinder.readAllByTokensIn(initialTokens); + log.info("Max attempt reached for title: {}, body: {}, failed: {}", notification.getTitle(), + notification.getBody(), tokens); + return; + } + ApiFuture future = FirebaseMessaging.getInstance().sendEachForMulticastAsync(message); ApiFutures.addCallback(future, new ApiFutureCallback<>() { @Override public void onFailure(Throwable t) { if (t instanceof FirebaseMessagingException exception) { - log.error("Error Sending Message. error code: {}, messaging error code: {}, error message: {}", - exception.getErrorCode(), exception.getMessagingErrorCode(), exception.getMessage()); + log.error( + "Error Sending Message. title: {}, body: {}, error code: {}, messaging error code: {}, error message: {}", + notification.getTitle(), notification.getBody(), exception.getMessagingErrorCode(), + exception.getMessagingErrorCode(), exception.getMessage() + ); } + sendMulticastMessage(notification, message, initialTokens, attempt + 1); } @Override public void onSuccess(BatchResponse result) { if (result.getFailureCount() == 0) { - log.info("All messages were sent successfully. message: {}", notification.getTitle()); - return; - } - List registeredTokens = checkUnregisteredTokensAndDelete(result, initialTokens); - if (registeredTokens.isEmpty()) { + log.info("All messages were sent successfully. title: {}, body: {}", notification.getTitle(), + notification.getBody()); return; } - fcmResponseHandler.handleBatchResponse(result, notification, registeredTokens); + fcmResponseHandler.handleBatchResponse(result, notification, initialTokens); } }, Executors.newFixedThreadPool(THREAD_POOL_SIZE_FOR_CALLBACK)); } - - /** - * @param batchResponse 처음 알림을 전송했을 때의 응답 - * @param tokens 처음 알림을 전송했을 때 사용한 토큰 - * @return 등록되지 않은(FCM 에서 Unregistered 를 응답한) 토큰을 제거한 뒤, 나머지의 토큰을 반환 - */ - private List checkUnregisteredTokensAndDelete(BatchResponse batchResponse, List tokens) { - tokens = new ArrayList<>(tokens); - List responses = batchResponse.getResponses(); - List unregisteredTokens = IntStream.range(0, responses.size()) - .filter(i -> isUnregistered(responses.get(i))) - .mapToObj(tokens::get) - .toList(); - - if (!unregisteredTokens.isEmpty()) { - log.info("{} of {} tokens are unregistered. Deleting them..", unregisteredTokens.size(), tokens.size()); - fcmTokenWriter.deleteAll(unregisteredTokens); - tokens.removeAll(unregisteredTokens); - } - - return tokens; - } - - private boolean isUnregistered(SendResponse response) { - if (response.isSuccessful()) { - return false; - } - MessagingErrorCode messagingErrorCode = response.getException().getMessagingErrorCode(); - return messagingErrorCode == MessagingErrorCode.UNREGISTERED; - } } diff --git a/backend/src/main/java/mouda/backend/notification/implement/fcm/FcmResponseHandler.java b/backend/src/main/java/mouda/backend/notification/implement/fcm/FcmResponseHandler.java index c402182f6..8393a1e68 100644 --- a/backend/src/main/java/mouda/backend/notification/implement/fcm/FcmResponseHandler.java +++ b/backend/src/main/java/mouda/backend/notification/implement/fcm/FcmResponseHandler.java @@ -17,6 +17,9 @@ import lombok.extern.slf4j.Slf4j; import mouda.backend.notification.domain.CommonNotification; import mouda.backend.notification.domain.FcmFailedResponse; +import mouda.backend.notification.domain.FcmToken; +import mouda.backend.notification.implement.fcm.token.FcmTokenFinder; +import mouda.backend.notification.implement.fcm.token.FcmTokenWriter; @Component @RequiredArgsConstructor @@ -29,67 +32,99 @@ public class FcmResponseHandler { private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(5); private final FcmMessageFactory fcmMessageFactory; + private final FcmTokenFinder fcmTokenFinder; + private final FcmTokenWriter fcmTokenWriter; @PreDestroy public void destroy() { scheduler.shutdown(); } - public void handleBatchResponse(BatchResponse batchResponse, CommonNotification notification, - List initialTokens) { - FcmFailedResponse failedResponse = FcmFailedResponse.from(batchResponse, initialTokens); + public void handleBatchResponse( + BatchResponse batchResponse, CommonNotification notification, List initialTokens + ) { + List tokens = fcmTokenFinder.readAllByTokensIn(initialTokens); + FcmFailedResponse failedResponse = FcmFailedResponse.from(batchResponse, tokens); int attempt = 1; retryAsync(notification, failedResponse, attempt, BACKOFF_DELAY_FOR_SECONDS); } - private void retryAsync(CommonNotification notification, FcmFailedResponse failedResponse, int attempt, - int backoffDelayForSeconds) { + private void retryAsync( + CommonNotification notification, FcmFailedResponse failedResponse, int attempt, int backoffDelayForSeconds + ) { if (attempt > MAX_ATTEMPT) { - log.info("Max attempt reached for notification: {}. failed: {}", notification.getBody(), - failedResponse.getFinallyFailedTokens()); + log.info("Max attempt reached for title: {}, body: {}, failed: {}", notification.getTitle(), + notification.getBody(), failedResponse.getFinallyFailedTokens()); + removeAllUnregisteredTokens(failedResponse.getFailedWith404Tokens()); return; } - if (failedResponse.hasNoRetryableTokens()) { - log.info("No Retryable tokens for notification: {}. failed: {}.", notification.getBody(), - failedResponse.getNonRetryableFailedTokens()); + log.info("No Retryable tokens for title: {}, body: {}, failed: {}.", notification.getTitle(), + notification.getBody(), failedResponse.getNonRetryableFailedTokens()); + removeAllUnregisteredTokens(failedResponse.getFailedWith404Tokens()); return; } + retryUsingRetryAfter(notification, failedResponse, attempt, backoffDelayForSeconds); + retryUsingBackoff(notification, failedResponse, attempt, backoffDelayForSeconds); + } - if (failedResponse.hasFailedWith429Tokens()) { - int retryAfterSeconds = failedResponse.getRetryAfterSeconds(); - scheduler.schedule(() -> { - log.info("Retrying 429 for notification: {}. Thread: {}", notification.getTitle(), - Thread.currentThread().getName()); - FcmFailedResponse retryResponse = retry(failedResponse, notification, - failedResponse.getFailedWith429Tokens()); - retryAsync(notification, retryResponse, attempt + 1, backoffDelayForSeconds * BACKOFF_MULTIPLIER); - }, retryAfterSeconds, TimeUnit.SECONDS); + private void removeAllUnregisteredTokens(List failedWith404Tokens) { + if (failedWith404Tokens.isEmpty()) { + return; } + log.info("Removing all unregistered tokens: {}", failedWith404Tokens); + List tokens = failedWith404Tokens.stream().map(FcmToken::getToken).toList(); - if (failedResponse.hasFailedWith5xxTokens()) { - scheduler.schedule(() -> { - log.info("Retrying 5xx for notification: {}. Thread: {}", notification.getTitle(), - Thread.currentThread().getName()); - FcmFailedResponse retryResponse = retry(failedResponse, notification, - failedResponse.getFailedWith5xxTokens()); - retryAsync(notification, retryResponse, attempt + 1, backoffDelayForSeconds * BACKOFF_MULTIPLIER); - }, backoffDelayForSeconds, TimeUnit.SECONDS); + fcmTokenWriter.deleteAll(tokens); + } + + private void retryUsingRetryAfter( + CommonNotification notification, FcmFailedResponse failedResponse, int attempt, int backOffDelayForSeconds + ) { + List failedWith429Tokens = failedResponse.getFailedWith429Tokens(); + if (failedWith429Tokens.isEmpty()) { + return; } + + int retryAfterSeconds = failedResponse.getRetryAfterSeconds(); + scheduler.schedule(() -> { + log.info("Retrying 429 retry for title: {}, body: {}, tokens: {}.", notification.getTitle(), + notification.getBody(), failedWith429Tokens); + + FcmFailedResponse retryResponse = sendNotification(failedResponse, notification, failedWith429Tokens); + retryAsync(notification, retryResponse, attempt + 1, backOffDelayForSeconds * BACKOFF_MULTIPLIER); + }, retryAfterSeconds, TimeUnit.SECONDS); + } + + private void retryUsingBackoff( + CommonNotification notification, FcmFailedResponse failedResponse, int attempt, int backoffDelayForSeconds + ) { + List failedWith5xxTokens = failedResponse.getFailedWith5xxTokens(); + if (failedWith5xxTokens.isEmpty()) { + return; + } + + scheduler.schedule(() -> { + log.info("Retrying 5xx for title: {}, body: {}, tokens: {}.", notification.getTitle(), + notification.getBody(), failedWith5xxTokens); + FcmFailedResponse retryResponse = sendNotification(failedResponse, notification, failedWith5xxTokens); + retryAsync(notification, retryResponse, attempt + 1, backoffDelayForSeconds * BACKOFF_MULTIPLIER); + }, backoffDelayForSeconds, TimeUnit.SECONDS); } - private FcmFailedResponse retry(FcmFailedResponse origin, CommonNotification notification, - List retryTokens) { - log.info("Retrying for notification: {}. failed: {}, Thread: {}", notification.getTitle(), retryTokens, - Thread.currentThread().getName()); - MulticastMessage message = fcmMessageFactory.createMessage(notification, retryTokens).get(0); + private FcmFailedResponse sendNotification( + FcmFailedResponse origin, CommonNotification notification, List retryTokens + ) { + List tokens = retryTokens.stream().map(FcmToken::getToken).toList(); + MulticastMessage message = fcmMessageFactory.createMessage(notification, tokens).get(0); try { BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message); return FcmFailedResponse.from(response, retryTokens); } catch (FirebaseMessagingException e) { - log.error("Error Sending Message. error message: {}", e.getMessage()); + log.error("Error Sending Message while retrying.. title: {}, body: {}, error message: {}", + notification.getTitle(), notification.getBody(), e.getMessage()); return origin; } } diff --git a/backend/src/main/java/mouda/backend/notification/implement/fcm/token/FcmTokenFinder.java b/backend/src/main/java/mouda/backend/notification/implement/fcm/token/FcmTokenFinder.java index fda9990f4..1800d63eb 100644 --- a/backend/src/main/java/mouda/backend/notification/implement/fcm/token/FcmTokenFinder.java +++ b/backend/src/main/java/mouda/backend/notification/implement/fcm/token/FcmTokenFinder.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; +import mouda.backend.notification.domain.FcmToken; import mouda.backend.notification.domain.Recipient; import mouda.backend.notification.infrastructure.entity.FcmTokenEntity; import mouda.backend.notification.infrastructure.repository.FcmTokenRepository; @@ -26,4 +27,14 @@ public List findAllTokensByMember(List recipients) { } return fcmTokens; } + + public List readAllByTokensIn(List tokens) { + return fcmTokenRepository.findAllByTokenIn(tokens).stream() + .map(entity -> FcmToken.builder() + .memberId(entity.getMemberId()) + .token(entity.getToken()) + .build() + ) + .toList(); + } } diff --git a/backend/src/main/java/mouda/backend/notification/infrastructure/repository/FcmTokenRepository.java b/backend/src/main/java/mouda/backend/notification/infrastructure/repository/FcmTokenRepository.java index 9847c6422..1450cd0b0 100644 --- a/backend/src/main/java/mouda/backend/notification/infrastructure/repository/FcmTokenRepository.java +++ b/backend/src/main/java/mouda/backend/notification/infrastructure/repository/FcmTokenRepository.java @@ -14,4 +14,6 @@ public interface FcmTokenRepository extends JpaRepository Optional findByToken(String token); void deleteAllByTokenIn(List unregisteredTokens); + + List findAllByTokenIn(List tokens); } diff --git a/backend/src/main/java/mouda/backend/notification/util/FcmRetryAfterExtractor.java b/backend/src/main/java/mouda/backend/notification/util/FcmRetryAfterExtractor.java new file mode 100644 index 000000000..1934f45b3 --- /dev/null +++ b/backend/src/main/java/mouda/backend/notification/util/FcmRetryAfterExtractor.java @@ -0,0 +1,39 @@ +package mouda.backend.notification.util; + +import java.util.List; + +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MessagingErrorCode; +import com.google.firebase.messaging.SendResponse; + +public class FcmRetryAfterExtractor { + + private static final int DEFAULT_RETRY_AFTER_SECONDS = 60; + + public static int getRetryAfterSeconds(BatchResponse batchResponse) { + List responses = batchResponse.getResponses(); + return responses.stream() + .filter(FcmRetryAfterExtractor::isFailedWith429) + .map(FcmRetryAfterExtractor::parseRetryAfterSeconds) + .findAny() + .orElse(DEFAULT_RETRY_AFTER_SECONDS); + } + + private static boolean isFailedWith429(SendResponse response) { + return response.getException().getMessagingErrorCode() == MessagingErrorCode.QUOTA_EXCEEDED; + } + + private static int parseRetryAfterSeconds(SendResponse response) { + FirebaseMessagingException exception = response.getException(); + IncomingHttpResponse httpResponse = exception.getHttpResponse(); + Object retryAfterHeader = httpResponse.getHeaders().get("Retry-After"); + + try { + return Integer.parseInt(retryAfterHeader.toString()); + } catch (Exception e) { + return DEFAULT_RETRY_AFTER_SECONDS; + } + } +}