Skip to content

Commit 99a2b60

Browse files
committed
fix: NHN Cloud API 클라이언트들이 에러를 적절히 처리하지 못하던 문제 수정
1 parent 55a9a17 commit 99a2b60

8 files changed

Lines changed: 136 additions & 7 deletions

File tree

app/core/external_apis/__interface__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ class SendParameters(TypedDict):
99
template_code: str
1010

1111

12+
class NotificationSendError(Exception):
13+
"""발송 채널이 HTTP 200을 주면서도 응답 본문 기준으로 발송을 거부한 경우."""
14+
15+
1216
class NotificationServiceInterface(ABC):
1317
@abstractmethod
1418
def send_message(self, *, data: SendParameters) -> None: ...

app/core/external_apis/nhn_cloud/__init__.py

Whitespace-only changes.

app/core/external_apis/nhn_cloud_kakao_alimtalk.py renamed to app/core/external_apis/nhn_cloud/kakao_alimtalk.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from logging import getLogger
33
from typing import Any
44

5-
from core.external_apis.__interface__ import NotificationServiceInterface, SendParameters
5+
from core.external_apis.__interface__ import NotificationSendError, NotificationServiceInterface, SendParameters
66
from django.conf import settings
77
from httpx import Client
88

@@ -32,6 +32,13 @@ def send_message(self, *, data: SendParameters) -> None:
3232
"recipientList": [{"recipientNo": data["send_to"], "templateParameter": data["payload"]}],
3333
}
3434
result = self.session.post("/messages", json=body).raise_for_status().json()
35+
36+
# NHN은 발송 거부 시에도 HTTP 200을 주므로 수신자별 결과와 header를 직접 검증.
37+
if failed := [r for r in (result.get("message") or {}).get("sendResults", []) if r.get("resultCode") != 0]:
38+
detail = "; ".join(f"{r.get('recipientNo')}={r.get('resultCode')} {r.get('resultMessage')}" for r in failed)
39+
raise NotificationSendError(f"Alimtalk send failed: {detail}")
40+
if not result.get("header", {}).get("isSuccessful"):
41+
raise NotificationSendError(f"Alimtalk request failed: {result.get('header')}")
3542
logger.info(
3643
"Alimtalk send results: result_code=%s, result_message=%s",
3744
result["header"]["resultCode"],
Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from logging import getLogger
33
from typing import Any, TypedDict, cast
44

5-
from core.external_apis.__interface__ import NotificationServiceInterface, SendParameters
5+
from core.external_apis.__interface__ import NotificationSendError, NotificationServiceInterface, SendParameters
66
from django.conf import settings
77
from httpx import Client
88

@@ -50,6 +50,15 @@ def send_message(self, *, data: SendParameters) -> None:
5050
url = "/sender/sms"
5151

5252
result = self.session.post(url, json=body).raise_for_status().json()
53+
54+
# SMS는 개별 수신자 실패 시에도 header는 성공이므로 sendResultList까지 검증해야 한다.
55+
send_results = ((result.get("body") or {}).get("data") or {}).get("sendResultList", [])
56+
if failed := [r for r in send_results if r.get("resultCode") != 0]:
57+
detail = "; ".join(f"{r.get('recipientNo')}={r.get('resultCode')} {r.get('resultMessage')}" for r in failed)
58+
raise NotificationSendError(f"SMS send failed: {detail}")
59+
if not result.get("header", {}).get("isSuccessful"):
60+
raise NotificationSendError(f"SMS request failed: {result.get('header')}")
61+
5362
logger.info(
5463
"SMS send results: result_code=%s, result_message=%s",
5564
result["header"]["resultCode"],
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
from core.external_apis.__interface__ import NotificationSendError, SendParameters
5+
from core.external_apis.nhn_cloud.kakao_alimtalk import nhn_cloud_kakao_alimtalk_client
6+
7+
8+
@pytest.fixture
9+
def mock_session():
10+
"""싱글톤 클라이언트의 httpx 세션을 mock으로 교체. NHN 표준 성공 응답을 기본값으로 반환."""
11+
with patch.object(nhn_cloud_kakao_alimtalk_client, "session") as session:
12+
response = MagicMock()
13+
response.raise_for_status.return_value = response
14+
response.json.return_value = {
15+
"header": {"isSuccessful": True, "resultCode": 0, "resultMessage": "success"},
16+
"message": {"sendResults": [{"recipientNo": "01012345678", "resultCode": 0, "resultMessage": "SUCCESS"}]},
17+
}
18+
session.post.return_value = response
19+
yield session
20+
21+
22+
def _params(**overrides) -> SendParameters:
23+
return SendParameters(
24+
payload=overrides.pop("payload", {"customer_name": "홍길동"}),
25+
send_to=overrides.pop("send_to", "01012345678"),
26+
sent_from=overrides.pop("sent_from", "SENDER-KEY"),
27+
template_code=overrides.pop("template_code", "tpl_1"),
28+
)
29+
30+
31+
def test_send_message_raises_if_sent_from_is_empty():
32+
with pytest.raises(ValueError, match="sent_from"):
33+
nhn_cloud_kakao_alimtalk_client.send_message(data=_params(sent_from=""))
34+
35+
36+
def test_send_message_posts_expected_body(mock_session):
37+
nhn_cloud_kakao_alimtalk_client.send_message(data=_params())
38+
39+
mock_session.post.assert_called_once_with(
40+
"/messages",
41+
json={
42+
"senderKey": "SENDER-KEY",
43+
"templateCode": "tpl_1",
44+
"recipientList": [{"recipientNo": "01012345678", "templateParameter": {"customer_name": "홍길동"}}],
45+
},
46+
)
47+
48+
49+
def test_send_message_raises_when_recipient_failed_with_http_200(mock_session):
50+
# NHN은 수신자가 거부돼도 HTTP 200 + header.isSuccessful=false 로 응답한다.
51+
mock_session.post.return_value.json.return_value = {
52+
"header": {"isSuccessful": False, "resultCode": -1031, "resultMessage": "All of receivers are failed to send."},
53+
"message": {
54+
"sendResults": [
55+
{"recipientNo": "01012345678", "resultCode": -1028, "resultMessage": "Blacklist can't use ..."}
56+
]
57+
},
58+
}
59+
with pytest.raises(NotificationSendError, match="-1028"):
60+
nhn_cloud_kakao_alimtalk_client.send_message(data=_params())
61+
62+
63+
def test_send_message_raises_when_header_unsuccessful_without_send_results(mock_session):
64+
mock_session.post.return_value.json.return_value = {
65+
"header": {"isSuccessful": False, "resultCode": -1, "resultMessage": "auth failed"},
66+
}
67+
with pytest.raises(NotificationSendError, match="Alimtalk"):
68+
nhn_cloud_kakao_alimtalk_client.send_message(data=_params())
69+
70+
71+
def test_send_message_raises_cleanly_when_message_is_null(mock_session):
72+
# NHN이 message를 null로 주더라도 AttributeError가 아니라 명확한 예외여야 한다.
73+
mock_session.post.return_value.json.return_value = {
74+
"header": {"isSuccessful": False, "resultCode": -1, "resultMessage": "fail"},
75+
"message": None,
76+
}
77+
with pytest.raises(NotificationSendError, match="Alimtalk"):
78+
nhn_cloud_kakao_alimtalk_client.send_message(data=_params())

app/core/test/nhn_cloud_sms_test.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
from unittest.mock import MagicMock, patch
33

44
import pytest
5-
from core.external_apis.__interface__ import SendParameters
6-
from core.external_apis.nhn_cloud_sms import nhn_cloud_sms_client
5+
from core.external_apis.__interface__ import NotificationSendError, SendParameters
6+
from core.external_apis.nhn_cloud.sms import nhn_cloud_sms_client
77

88

99
@pytest.fixture
@@ -97,7 +97,38 @@ def test_send_message_template_id_omitted_when_template_code_empty(mock_session)
9797

9898

9999
def test_send_message_logs_result_code_and_message_on_success(mock_session, caplog):
100-
with caplog.at_level(logging.INFO, logger="core.external_apis.nhn_cloud_sms"):
100+
with caplog.at_level(logging.INFO, logger="core.external_apis.nhn_cloud.sms"):
101101
nhn_cloud_sms_client.send_message(data=_params())
102102

103103
assert any("result_code=0" in r.getMessage() and "SUCCESS" in r.getMessage() for r in caplog.records)
104+
105+
106+
def test_send_message_raises_when_nhn_rejects_with_http_200(mock_session):
107+
# NHN은 발송을 거부해도 HTTP 200을 주므로 header.isSuccessful을 검증해야 한다.
108+
mock_session.post.return_value.json.return_value = {
109+
"header": {"isSuccessful": False, "resultCode": -1, "resultMessage": "fail"},
110+
}
111+
with pytest.raises(NotificationSendError, match="SMS"):
112+
nhn_cloud_sms_client.send_message(data=_params())
113+
114+
115+
def test_send_message_raises_when_recipient_failed_but_header_successful(mock_session):
116+
# SMS는 개별 수신자가 실패해도 header.isSuccessful=true 라 sendResultList까지 봐야 한다.
117+
mock_session.post.return_value.json.return_value = {
118+
"header": {"isSuccessful": True, "resultCode": 0, "resultMessage": "SUCCESS"},
119+
"body": {
120+
"data": {"sendResultList": [{"recipientNo": "01012345678", "resultCode": 5, "resultMessage": "fail"}]}
121+
},
122+
}
123+
with pytest.raises(NotificationSendError, match="01012345678"):
124+
nhn_cloud_sms_client.send_message(data=_params())
125+
126+
127+
def test_send_message_raises_cleanly_when_body_is_null(mock_session):
128+
# 요청 단위 실패 시 NHN이 body/data를 null로 줄 수 있다. AttributeError가 아니라 명확한 예외여야 한다.
129+
mock_session.post.return_value.json.return_value = {
130+
"header": {"isSuccessful": False, "resultCode": -1, "resultMessage": "fail"},
131+
"body": {"data": None},
132+
}
133+
with pytest.raises(NotificationSendError, match="SMS"):
134+
nhn_cloud_sms_client.send_message(data=_params())

app/notification/models/nhn_cloud_kakao_alimtalk.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from re import compile as re_compile
22
from typing import Any, ClassVar, Self
33

4-
from core.external_apis.nhn_cloud_kakao_alimtalk import NHNCloudKakaoAlimTalkClient, nhn_cloud_kakao_alimtalk_client
4+
from core.external_apis.nhn_cloud.kakao_alimtalk import NHNCloudKakaoAlimTalkClient, nhn_cloud_kakao_alimtalk_client
55
from core.logger.util.django_helper import default_json_dumps
66
from core.models import BaseAbstractModelQuerySet
77
from django.db import models, transaction

app/notification/models/nhn_cloud_sms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import ClassVar
22

3-
from core.external_apis.nhn_cloud_sms import NHNCloudSMSClient, nhn_cloud_sms_client
3+
from core.external_apis.nhn_cloud.sms import NHNCloudSMSClient, nhn_cloud_sms_client
44
from django.db import models
55
from notification.models.base import (
66
NotificationHistoryBase,

0 commit comments

Comments
 (0)