Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

알림 재전송시 발생하는 오류 해결 및 로깅 개선 #696

Merged
merged 3 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> failedWith429Tokens;
private final List<String> failedWith5xxTokens;
private final List<String> nonRetryableFailedTokens;

private final Map<MessagingErrorCode, List<FcmToken>> failedTokens;

public static FcmFailedResponse from(BatchResponse response, List<String> triedTokens) {
public static FcmFailedResponse from(BatchResponse response, List<FcmToken> triedTokens) {
Map<MessagingErrorCode, List<FcmToken>> result = new ConcurrentHashMap<>();
List<SendResponse> responses = response.getResponses();
List<String> failedWith429Tokens = new ArrayList<>();
List<String> failedWith5xxTokens = new ArrayList<>();
List<String> 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<FcmToken> getFailedWith404Tokens() {
return getTokens(this::isFailedWith404);
}

private static boolean isFailedWith5xx(SendResponse response) {
return hasSameErrorCode(response, MessagingErrorCode.INTERNAL, MessagingErrorCode.UNAVAILABLE);
public List<FcmToken> 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<FcmToken> getFailedWith5xxTokens() {
return getTokens(this::isFailedWith5xx);
}

public List<FcmToken> getNonRetryableFailedTokens() {
return getTokens(errorCode -> !isFailedWith429(errorCode) && !isFailedWith5xx(errorCode));
}

public List<FcmToken> 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<FcmToken> getTokens(Predicate<MessagingErrorCode> 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<String> getFinallyFailedTokens() {
List<String> 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<SendResponse> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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 +
'}';
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<Recipient> recipients) {
Expand All @@ -48,64 +44,44 @@ private void sendAllMulticastMessage(CommonNotification notification, List<Strin
return;
}

int attempt = 1;
fcmMessageFactory.createMessage(notification, tokens)
.forEach(multicastMessage -> sendMulticastMessage(notification, multicastMessage, tokens));
.forEach(multicastMessage -> sendMulticastMessage(notification, multicastMessage, tokens, attempt));
}

private void sendMulticastMessage(CommonNotification notification, MulticastMessage message,
List<String> initialTokens) {
private void sendMulticastMessage(
CommonNotification notification, MulticastMessage message, List<String> initialTokens, int attempt
) {
if (attempt > MAX_ATTEMPT) {
List<FcmToken> tokens = fcmTokenFinder.readAllByTokensIn(initialTokens);
log.info("Max attempt reached for title: {}, body: {}, failed: {}", notification.getTitle(),
notification.getBody(), tokens);
return;
}

ApiFuture<BatchResponse> 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<String> 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<String> checkUnregisteredTokensAndDelete(BatchResponse batchResponse, List<String> tokens) {
tokens = new ArrayList<>(tokens);
List<SendResponse> responses = batchResponse.getResponses();
List<String> 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;
}
}
Loading