fix(flutter): 토큰 갱신 429 로 로그아웃되지 않게, 갱신 뒤 사진 다시 보내기

리뷰에서 나온 앱 쪽 문제를 고친다.
- 토큰 갱신이 429 를 받으면 세션을 끊던 문제 — 거절(401·400)만 세션을 끊고, 429 는 REQ_001·그 밖은 SYS_001 로 알린다
  (서버는 갱신을 IP 당 분당 10회로 막아 통신사 망처럼 여럿이 한 IP 를 쓰면 실제로 생긴다)
- 액세스 토큰이 만료된 뒤 사진을 올리면 다시 보낼 때 FormData 가 이미 쓰여 실패하던 문제 — 복사본으로 다시 보낸다
- 에러 사전 계약 테스트에 앱이 직접 쓰는 도메인 코드(FILE·DIARY·CARD·SCHED)를 더한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 22:57:34 +09:00
parent 3f84d7b747
commit 043e14a3fa
3 changed files with 60 additions and 7 deletions
+17 -7
View File
@@ -24,7 +24,8 @@ const String _kRetriedExtra = 'authRetried';
const String _kRefreshUnreachableExtra = 'refreshUnreachable';
/// 토큰 갱신 결과
enum _RefreshResult { refreshed, rejected, unreachable }
/// 토큰 갱신 결과 — 거절(401·400)만 세션을 끊는다. 서버에 닿지 못했거나 너무 잦아 막혔으면(429) 세션은 그대로 둔다
enum _RefreshResult { refreshed, rejected, unreachable, throttled }
/// 토큰 갱신 경로 (backend AuthController `POST /auth/tokens`)
const String kRefreshPath = '/auth/tokens';
@@ -134,12 +135,17 @@ class DioClient {
if (sent != 'Bearer ${tokens.accessToken}') return true;
final result = await _refreshOnce();
if (result == _RefreshResult.unreachable) options.extra[_kRefreshUnreachableExtra] = true;
// 갱신하지 못했지만 다시 로그인할 일은 아닐 때 — 이번 요청이 어떤 코드로 실패할지 남겨 둔다
if (result == _RefreshResult.unreachable) options.extra[_kRefreshUnreachableExtra] = 'SYS_001';
if (result == _RefreshResult.throttled) options.extra[_kRefreshUnreachableExtra] = 'REQ_001';
return result == _RefreshResult.refreshed;
}
RequestOptions _withFreshToken(RequestOptions options) {
options.extra[_kRetriedExtra] = true;
// 사진 올리기(FormData)는 한 번 보내면 다시 쓸 수 없다 — 다시 보낼 때는 복사본으로
final data = options.data;
if (data is FormData) options.data = data.clone();
final token = _session.tokens?.accessToken;
if (token != null) options.headers['Authorization'] = 'Bearer $token';
return options;
@@ -162,9 +168,12 @@ class DioClient {
await _session.save(AuthTokens.fromJson(data));
return _RefreshResult.refreshed;
} on DioException catch (e) {
final response = e.response;
if (response == null || (response.statusCode ?? 0) >= 500) return _RefreshResult.unreachable;
return _RefreshResult.rejected;
final status = e.response?.statusCode;
// 서버가 리프레시 토큰을 받아들이지 않았을 때(401·형식 오류 400)만 세션을 끊는다.
// 429(한 IP 를 여럿이 쓰는 통신사 망에서도 생긴다)·5xx·응답 없음은 잠깐의 문제라 세션을 둔다
if (status == 401 || status == 400) return _RefreshResult.rejected;
if (status == 429) return _RefreshResult.throttled;
return _RefreshResult.unreachable;
}
}
@@ -194,8 +203,9 @@ class DioClient {
// 갱신하려다 서버에 닿지 못했으면 다시 로그인할 일이 아니므로 일시적 오류(SYS_001)로 둔다.
// AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
if (code == 'AUTH_001' && e.requestOptions.headers['Authorization'] != null) {
if (e.requestOptions.extra[_kRefreshUnreachableExtra] == true) {
code = 'SYS_001';
final kept = e.requestOptions.extra[_kRefreshUnreachableExtra];
if (kept is String) {
code = kept;
} else {
if (_session.hasSession) await _session.clear(expired: true);
code = 'AUTH_002';
+28
View File
@@ -169,6 +169,34 @@ void main() {
expect(t.session.hasSession, isTrue);
});
test('갱신이 너무 잦아 막히면(429) 세션은 두고 요청 과다(REQ_001)로 알린다', () async {
final t = build((o) => o.path == kRefreshPath
? (status: 429, body: _fail('REQ_001'))
: (status: 401, body: _fail('AUTH_001')));
final error = await errorOf(t.client.dio.get<Object?>('/pets'));
expect(error.code, 'REQ_001');
expect(t.session.hasSession, isTrue, reason: '한 IP 를 여럿이 쓰는 망에서도 생기므로 로그아웃시키지 않는다');
});
test('사진 올리기(FormData)도 토큰을 갱신한 뒤 다시 보낼 수 있다', () async {
final t = build((o) {
if (o.path == kRefreshPath) return (status: 201, body: _newTokens());
return _auth(o) == 'Bearer new-access-1'
? (status: 201, body: _ok({'id': 'f1'}))
: (status: 401, body: _fail('AUTH_001'));
});
final res = await t.client.dio.post<Map<String, dynamic>>(
'/files',
data: FormData.fromMap({'file': MultipartFile.fromBytes(const [1, 2, 3], filename: 'a.png')}),
);
expect(res.data, {'id': 'f1'});
expect(reported, isEmpty);
});
test('로그인 전(토큰 없음)의 401 은 그대로 AUTH_001 이다', () async {
final t = build((o) => (status: 401, body: _fail('AUTH_001')), tokens: null);
+15
View File
@@ -40,6 +40,21 @@ void main() {
}
});
test('화면이 분기하거나 같은 문구로 안내하는 도메인 코드가 사전에 존재한다', () {
// 이름이 바뀌면 조용히 SYS_001 문구로 떨어지므로, 앱 코드가 직접 쓰는 도메인 코드를 지킨다
// (checklist 3절 — 클라이언트가 그 코드로 분기하면 목록에 추가한다)
const domainUsedByClient = [
'FILE_001', 'FILE_002', // 사진 올리기
'DIARY_001', 'DIARY_002', 'DIARY_003', 'DIARY_004', 'DIARY_005', // 다이어리 책장·하루 한 번·초대
'CARD_001', 'CARD_002', 'CARD_003', // 명함 한도·받기
'SCHED_001', 'SCHED_002', 'SCHED_003', 'SCHED_004', 'SCHED_005', // 일정 연동
];
for (final code in domainUsedByClient) {
expect(ErrorLexicon.contains(code), isTrue, reason: '$code 누락');
}
});
test('모든 코드는 DOMAIN_NNN 네이밍 규약을 따른다', () {
final pattern = RegExp(r'^[A-Z]+_\d{3}$');