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}$');