From 043e14a3fa769dd5db146c02cfb55d685344746e Mon Sep 17 00:00:00 2001 From: TtiPo Date: Sun, 11 Oct 2026 22:57:34 +0900 Subject: [PATCH] =?UTF-8?q?fix(flutter):=20=ED=86=A0=ED=81=B0=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20429=20=EB=A1=9C=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=EB=90=98=EC=A7=80=20=EC=95=8A=EA=B2=8C,=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EB=92=A4=20=EC=82=AC=EC=A7=84=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EB=B3=B4=EB=82=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰에서 나온 앱 쪽 문제를 고친다. - 토큰 갱신이 429 를 받으면 세션을 끊던 문제 — 거절(401·400)만 세션을 끊고, 429 는 REQ_001·그 밖은 SYS_001 로 알린다 (서버는 갱신을 IP 당 분당 10회로 막아 통신사 망처럼 여럿이 한 IP 를 쓰면 실제로 생긴다) - 액세스 토큰이 만료된 뒤 사진을 올리면 다시 보낼 때 FormData 가 이미 쓰여 실패하던 문제 — 복사본으로 다시 보낸다 - 에러 사전 계약 테스트에 앱이 직접 쓰는 도메인 코드(FILE·DIARY·CARD·SCHED)를 더한다 Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/api/dio_client.dart | 24 +++++++++++++++++------- flutter/test/api_client_test.dart | 28 ++++++++++++++++++++++++++++ flutter/test/error_lexicon_test.dart | 15 +++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/flutter/lib/core/api/dio_client.dart b/flutter/lib/core/api/dio_client.dart index 26cac72..c2b65c7 100644 --- a/flutter/lib/core/api/dio_client.dart +++ b/flutter/lib/core/api/dio_client.dart @@ -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'; diff --git a/flutter/test/api_client_test.dart b/flutter/test/api_client_test.dart index 3bde12f..298d594 100644 --- a/flutter/test/api_client_test.dart +++ b/flutter/test/api_client_test.dart @@ -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('/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>( + '/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); diff --git a/flutter/test/error_lexicon_test.dart b/flutter/test/error_lexicon_test.dart index 1998e5f..6b58882 100644 --- a/flutter/test/error_lexicon_test.dart +++ b/flutter/test/error_lexicon_test.dart @@ -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}$');