Skip to content
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 @@ -49,7 +49,7 @@ import com.afternote.feature.afternote.domain.model.LeaveMessageBlock
import com.afternote.feature.home.presentation.HomeTabActions
import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords
import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository
import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException
import com.afternote.feature.receiver.domain.error.ReceiverFailure
import com.afternote.feature.receiver.domain.model.AfterNoteListItem
import com.afternote.feature.receiver.domain.model.AfterNotesListResult
import com.afternote.feature.receiver.domain.model.DeliveryVerification
Expand Down Expand Up @@ -344,7 +344,7 @@ class ReceiverRuntimeCompletionAndroidTest {
val authRepository = CompletionReceiverAuthRepository()
authRepository.verifyEmailResults.addLast(
Result.failure(
ReceiverEmailAuthException(
ReceiverFailure.ServerRejection(
status = 400,
serverMessage = "인증번호가 만료되었습니다. 다시 발급해 주세요.",
serverCode = 1902,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.afternote.afternote_fe.test.FailureArtifactRule
import com.afternote.afternote_fe.test.FakeErrorReporter
import com.afternote.core.ui.theme.AfternoteTheme
import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException
import com.afternote.feature.receiver.domain.error.ReceiverFailure
import com.afternote.feature.receiver.domain.model.DeliveryVerification
import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus
import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl
Expand Down Expand Up @@ -51,7 +51,7 @@ class ReceiverVerificationAndroidTest {
val auth = FakeReceiverAuthRepository()
auth.verifyEmailResults.addLast(
Result.failure(
ReceiverEmailAuthException(
ReceiverFailure.ServerRejection(
status = 400,
serverMessage = "인증번호가 일치하지 않습니다.",
serverCode = 1903,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.afternote.feature.afternote.presentation.reporting

import com.afternote.core.common.reporting.ErrorReporter
import com.afternote.feature.receiver.domain.error.ReceiverServerRejectionException
import com.afternote.feature.receiver.domain.error.ReceiverFailure

/**
* 애프터노트 흐름에서 실패가 발생한 지점.
Expand Down Expand Up @@ -76,11 +76,6 @@ enum class AfternoteFailureStage(

/** 발신자 상세의 열람 인증 상태 조회. */
SENDER_STATUS_LOAD("sender_status_load"),

// RECEIVED_EXPORT_DOWNLOAD·RECEIVED_EXPORT_SAVE 는 수신자 흐름 이전(#615)과 함께
// feature:receiver 의 ReceiverFailureStage(`receiver_stage` 키)로 이동 — home_stage 와의
// 이중 기록(#546 참고)이 그 키로 수렴된다. 위 수신자 단계들(RECEIVED_*·RECEIVER_* 등)의
// 이동은 열람 실패 처리 통일(#611·#614 계열)과 함께 별도 판단.
}

/**
Expand All @@ -104,44 +99,40 @@ fun ErrorReporter.recordAfternoteFailure(
* `Receiver` 는 타입이 아니라 적용 맥락이다 — `Throwable` 확장이라 네트워크 타임아웃처럼 수신자와
* 무관한 예외도 들어오고, 그런 실패는 기록 대상(true)이다. 타입으로 좁히는 건 아래 제외 판정뿐이다.
*
* 유일한 제외 대상은 "서버가 예상하고 처리한 거절"(이메일 미등록·인증번호 만료·마스터 키 오타 등)이다 —
* 앱이 정상 동작한 결과라 고칠 것이 없고, 무엇보다 보관 한도를 사용자 오류가 차지해 실제 장애를 밀어낸다.
* Crashlytics 는 non-fatal 을 **최근 8건만 보관하고 초과분은 오래된 것부터 버린다** — 이 수치가 코드 곳곳의
* "보관 한도(최근 8건)" 서술의 근거다.
* 제외 대상은 "서버가 예상하고 처리한 거절" 하나다 — 앱이 정상 동작한 결과라 고칠 것이 없고, 무엇보다
* Crashlytics 가 non-fatal 을 **최근 8건만 보관하고 초과분은 오래된 것부터 버려서**(코드 곳곳의
* "보관 한도(최근 8건)" 서술이 이 수치를 가리킨다) 사용자 오류가 실제 장애를 밀어낸다.
* https://firebase.google.com/docs/crashlytics/android/customize-crash-reports
*
* 그 판정은 **4xx + 서버 문구** 두 조건을 모두 만족할 때만 성립하고, 나머지는 전부 기록한다.
*
* | | 문구 있음 | 문구 없음 |
* |---|---|---|
* | 4xx | 제외 | 기록 |
* | 5xx | 기록 | 기록 |
*
* 문구 유무만으로 가르지 않는 이유: 이 서버는 5xx 에도 `message` 를 싣는다 — 500 응답 body 에 내부 SQL
* 문구가 그대로 실려 온 실측(#511)이 있다. 문구만 보면 정작 잡으려던 장애가 통째로 제외된다.
*
* 반대로 status 만으로도 가를 수 없다: 문구 없는 4xx 는 서버가 안내한 거절이 아니라 이쪽이 잘못된 요청을
* 보내고 있다는 신호(파라미터 누락·잘못된 id·만료 토큰)라, 제외하면 FE 버그가 묻힌다. 이 서버는 4xx·5xx
* 가리지 않고 문구를 싣는 것으로 관측돼(400·401·500 실측), 문구가 없다는 것 자체가 정상 경로가 아니다.
*
* 두 조건을 모두 요구하는 건 **확실할 때만 제외**하려는 것이다 — 판정이 빗나가면 기록을 더 하는 쪽으로
* 빗나간다. 잡음은 콘솔에 보여서 나중에 좁힐 수 있지만, 제외한 건 보이지 않아 좁힐 기회조차 없다.
* 제외는 **4xx + 서버 문구** 둘 다일 때만 성립한다. 문구만 보면 5xx 장애가 통째로 빠지고(이 서버는 5xx 에도
* `message` 를 싣는다 — 500 body 에 내부 SQL 이 실려 온 실측 #511), status 만 보면 문구 없는 4xx —
* 파라미터 누락·만료 토큰 같은 FE 버그 신호 — 가 묻힌다. 판정이 빗나갈 땐 기록을 더 하는 쪽으로 빗나가야
* 한다: 잡음은 콘솔에 보여 나중에 좁힐 수 있지만, 제외한 건 보이지 않아 좁힐 기회조차 없다.
*
* 사유 code 로 좁히지 않은 이유: 서버의 code 체계가 사용자 오류와 장애를 아직 분리하지 않는다.
* 사유 code 로 좁히지 않은 이유 — 서버의 code 체계가 사용자 오류와 장애를 아직 분리하지 않는다.
*
* 다른 흐름에는 쓰지 않는다. 회원가입 이메일 인증은 사용자 오류가 code 1207 하나로만 와서 호출부가
* 타입(`CoreAuthFailure.EmailVerification`)만 보고 거른다 — 문구 유무를 따질 필요가 없다.
*
* 화면 노출 게이트(`toErrorPayload`, DocumentUploadUiState.kt)는 이 술어를 쓰지 않는다 — 노출은
* 사유 code allowlist 로 더 좁게 가른다. 그쪽을 넓히더라도 이 판정을 따라 넓히지 말 것.
*/
fun Throwable.shouldReportInReceiverFlow(): Boolean {
val isExpectedUserRejection =
this is ReceiverServerRejectionException &&
status in CLIENT_ERROR_STATUS_RANGE &&
!serverMessage.isNullOrBlank()
return !isExpectedUserRejection
}
fun Throwable.shouldReportInReceiverFlow(): Boolean =
when (this) {
is ReceiverFailure -> !isExpectedUserRejection()
else -> true
}

/**
* 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 수신자 실패 유형이 늘면 여기가 컴파일 에러로 잡힌다.
* 판정을 빼먹은 새 유형은 조용히 기록 대상이 되므로(안전한 쪽), 놓쳐도 티가 나지 않는다.
*/
private fun ReceiverFailure.isExpectedUserRejection(): Boolean =
when (this) {
is ReceiverFailure.ServerRejection -> {
status in CLIENT_ERROR_STATUS_RANGE && !serverMessage.isNullOrBlank()
}
}

/** 4xx = 요청을 보낸 쪽 문제. 이 대역 밖(5xx·그 외)은 서버 문구가 실려 와도 장애로 보고 기록한다. */
private val CLIENT_ERROR_STATUS_RANGE = 400..499
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package com.afternote.feature.afternote.presentation.reporting

import com.afternote.feature.receiver.domain.error.ReceiverDeliveryVerificationException
import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException
import com.afternote.feature.receiver.domain.error.ReceiverMasterKeyException
import com.afternote.feature.receiver.domain.error.ReceiverFailure
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
Expand All @@ -18,7 +16,7 @@ class ReceiverFlowReportingTest {
@Test
fun `4xx 에 서버 문구가 실렸으면 사용자 오류라 제외한다`() {
val rejection =
ReceiverEmailAuthException(
ReceiverFailure.ServerRejection(
status = 400,
serverMessage = "인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.",
serverCode = 1902,
Expand All @@ -30,7 +28,7 @@ class ReceiverFlowReportingTest {
@Test
fun `5xx 는 서버 문구가 실려 있어도 기록 대상이다`() {
val outage =
ReceiverMasterKeyException(
ReceiverFailure.ServerRejection(
status = 500,
serverMessage = "서버 내부 오류: could not execute statement",
serverCode = 1500,
Expand All @@ -42,7 +40,7 @@ class ReceiverFlowReportingTest {
@Test
fun `4xx 라도 서버 문구가 없으면 기록 대상이다`() {
val silentRejection =
ReceiverDeliveryVerificationException(
ReceiverFailure.ServerRejection(
status = 409,
serverMessage = null,
serverCode = 1700,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyRequestDto
import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto
import com.afternote.feature.receiver.data.dto.toDomain
import com.afternote.feature.receiver.data.service.ReceiverAuthApiService
import com.afternote.feature.receiver.domain.error.ReceiverDeliveryVerificationException
import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException
import com.afternote.feature.receiver.domain.error.ReceiverMasterKeyException
import com.afternote.feature.receiver.domain.error.ReceiverFailure
import com.afternote.feature.receiver.domain.model.DeliveryVerification
import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl
import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult
Expand Down Expand Up @@ -43,7 +41,7 @@ class ReceiverAuthRepositoryImpl
try {
api.verifyMasterKey(ReceiverAuthVerifyRequestDto(authCode)).requireData().toDomain()
} catch (e: ApiException) {
throw ReceiverMasterKeyException(status = e.status, serverMessage = e.serverMessage, serverCode = e.code)
throw ReceiverFailure.ServerRejection(status = e.status, serverMessage = e.serverMessage, serverCode = e.code)
}
}

Expand All @@ -52,7 +50,7 @@ class ReceiverAuthRepositoryImpl
try {
api.sendEmailAuthCode(ReceiverAuthCodeEmailSendRequestDto(email)).requireStatus()
} catch (e: ApiException) {
throw ReceiverEmailAuthException(status = e.status, serverMessage = e.serverMessage, serverCode = e.code)
throw ReceiverFailure.ServerRejection(status = e.status, serverMessage = e.serverMessage, serverCode = e.code)
}
}

Expand All @@ -68,7 +66,7 @@ class ReceiverAuthRepositoryImpl
).requireData()
.toDomain()
} catch (e: ApiException) {
throw ReceiverEmailAuthException(status = e.status, serverMessage = e.serverMessage, serverCode = e.code)
throw ReceiverFailure.ServerRejection(status = e.status, serverMessage = e.serverMessage, serverCode = e.code)
}
}

Expand All @@ -92,7 +90,7 @@ class ReceiverAuthRepositoryImpl
).requireData()
.toDomain()
} catch (e: ApiException) {
throw ReceiverDeliveryVerificationException(
throw ReceiverFailure.ServerRejection(
status = e.status,
serverMessage = e.serverMessage,
serverCode = e.code,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyDto
import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto
import com.afternote.feature.receiver.data.dto.ReceiverMessageDto
import com.afternote.feature.receiver.data.service.ReceiverAuthApiService
import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException
import com.afternote.feature.receiver.domain.error.ReceiverFailure
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
Expand All @@ -25,7 +25,7 @@ import org.junit.Test
import java.io.IOException

/**
* 이메일 인증 두 endpoint 의 `ApiException` → [ReceiverEmailAuthException] 도메인 예외 변환 회귀 가드 (#407).
* 이메일 인증 두 endpoint 의 `ApiException` → [ReceiverFailure.ServerRejection] 도메인 예외 변환 회귀 가드 (#407).
*
* presentation 은 core:network 의 ApiException 을 직접 알면 안 되므로 (layer 규약)
* Impl 이 serverMessage 를 보존해 변환하는지가 계약. 에러 메시지·code 값은
Expand Down Expand Up @@ -55,8 +55,8 @@ class ReceiverAuthRepositoryImplEmailAuthTest {
val result = runBlocking { repository.sendEmailAuthCode("none@example.com") }

val exception = result.exceptionOrNull()
assertTrue(exception is ReceiverEmailAuthException)
exception as ReceiverEmailAuthException
assertTrue(exception is ReceiverFailure.ServerRejection)
exception as ReceiverFailure.ServerRejection
assertEquals("등록된 수신자 이메일이 아닙니다.", exception.serverMessage)
assertEquals(404, exception.status)
}
Expand All @@ -80,8 +80,8 @@ class ReceiverAuthRepositoryImplEmailAuthTest {
val result = runBlocking { repository.verifyEmailAuthCode("a@b.com", "123456") }

val exception = result.exceptionOrNull()
assertTrue(exception is ReceiverEmailAuthException)
exception as ReceiverEmailAuthException
assertTrue(exception is ReceiverFailure.ServerRejection)
exception as ReceiverFailure.ServerRejection
assertEquals("인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", exception.serverMessage)
assertEquals(400, exception.status)
}
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.afternote.feature.receiver.domain.error

/**
* 수신자 흐름(본인 확인 이메일 인증·마스터 키 검증·전달 자격 심사)의 도메인 실패 루트.
* Retrofit·`BaseResponse` 같은 인프라 디테일은 Data 계층이 해석한 뒤 이 계열로 통일한다.
*
* 소비처는 이 루트로 좁힌 뒤 `when` 으로 가른다 — 실패 유형이 늘면 컴파일러가 소비처를 잡아준다.
*/
sealed class ReceiverFailure(
message: String?,
) : Exception(message) {
/**
* 서버가 응답을 내려주며 거절한 실패.
*
* 흐름별로 타입을 나누지 않는다 — 어느 endpoint 가 거절했는지는 stack trace 에 남고, 타입으로
* 갈라 보던 프로덕션 코드가 없었다(#934 실측). 구분이 실제로 필요해지면 그때 필드를 더한다.
*
* @property status 거절 응답의 HTTP 상태 코드. 인프라 디테일을 걷어내는 이 계열에 굳이 남기는 건
* "사용자 오류(4xx)"와 "장애(5xx)"를 가를 유일한 단서라서다 — 서버가 양쪽 모두에 [serverMessage] 를
* 실어 보내고(실측 #511), 사유 `code` 체계도 둘을 분리하지 않는다. 텔레메트리 제외 판정과
* 화면 노출 게이트(#651)가 이 값을 쓴다.
* @property serverCode 서버 봉투의 사유 code (BE `ErrorCode` 번호 — 1902 등). 화면 노출 게이트가
* 표시 허용 allowlist 판정에 쓴다. 서버의 `@Valid` 바디 검증 실패는 enum code 가 아니라
* **리터럴 400** 으로 온다(실측) — 형식 검증 문구가 allowlist 에 못 드는 건 이 때문이다.
* @property serverMessage 백엔드가 실제로 내려준 사용자 친화 message.
* **null 이면 서버가 message 미제공** — 호출처는 정적 R.string 으로 폴백한다. 클라가 만든 generic
* 문구("알 수 없는 서버 에러" 등)는 여기 들어오지 않는다.
* null 일 때 `Throwable.message` 로 쓰는 문구는 Logcat·Crashlytics 단서용이라 노출하지 않는다.
*/
class ServerRejection(
val status: Int,
val serverCode: Int,
val serverMessage: String?,
) : ReceiverFailure(
serverMessage ?: "receiver request rejected (status=$status, serverCode=$serverCode)",
)
}

This file was deleted.

Loading
Loading