Spring API与Android Retrofit2的登录失败及错误处理方案咨询
服务器端实现
我搭建了一个带认证的简易Spring API,仅引入了implementation("org.springframework.boot:spring-boot-starter-security")依赖,未登录时浏览器访问接口会跳转至登录页面。
当前使用基础认证,用户名和密码在配置文件resources/application.properties中设置:
spring.security.user.name=myusername spring.security.user.password=mypassword spring.security.user.roles=manager
同时使用了Spring Data REST,Spring会自动为项目中的JPA仓库生成API,为此我配置了数据库、创建了表对应的JPA仓库,并引入了implementation("org.springframework.boot:spring-boot-starter-data-rest")依赖。
Android端实现
通过以下Adapter和Client调用API:
interface ApiClient { @GET("inventoryItems/1") suspend fun getFirstInventoryItem(): Response<InventoryItemDto> } object ApiAdapter { private const val API_BASE_URL = "http://some.url/" private const val API_USERNAME = "myusername" private const val API_PASSWORD = "mypassword" val apiClient: ApiClient = Retrofit.Builder() .baseUrl(API_BASE_URL) .client(getHttpClient(API_USERNAME, API_PASSWORD)) .addConverterFactory(GsonConverterFactory.create()) .build() .create(ApiClient::class.java) private fun getHttpClient(user: String, pass: String): OkHttpClient = OkHttpClient .Builder() .authenticator(getBasicAuth(user,pass)) .build() private fun getBasicAuth(username: String?, password: String?): Authenticator? = object : Authenticator { override fun authenticate(route: Route?, response: okhttp3.Response): Request? { return response .request() .newBuilder() .addHeader("Authorization", Credentials.basic(username, password)) .build() } } }
调用代码(在Fragment的onViewCreated中执行):
lifecycleScope.launch { val item: InventoryItemDto? = ApiAdapter.apiClient.getFirstInventoryItem().body() binding?.tvTest?.text = item.toString() }
问题现象
输入正确密码时一切正常,但密码错误时Android应用崩溃,抛出java.net.ProtocolException: Too many follow-up requests: 21异常。推测原因是:客户端请求inventoryItems/1后被重定向到登录页,客户端添加Authorization头再次尝试认证,失败后又被重定向,形成循环。
问题1:如何在Android和/或Spring端正确处理登录失败?
问题2:如何在Android和/或Spring端正确处理其他错误(如请求错误)?
已尝试方案
- 在Android端禁用重定向:
private fun getHttpClient(user: String, pass: String): OkHttpClient = OkHttpClient .Builder() .followRedirects(false) .followSslRedirects(false) .authenticator(getBasicAuth(user,pass)) .build() - 添加请求头:
private fun getBasicAuth(username: String?, password: String?):Authenticator? = object : Authenticator { override fun authenticate(route: Route?, response: okhttp3.Response): Request? { return response .request() .newBuilder() .addHeader("Authorization", Credentials.basic(username, password)) .addHeader("X-Requested-With", "XMLHttpRequest") .build() } }
针对问题1:解决登录失败循环崩溃
1. Spring端调整:返回标准HTTP错误而非重定向
Spring Security默认对浏览器请求重定向到登录页,但对于API请求(非浏览器),应该直接返回401 Unauthorized,从根源避免重定向循环。配置示例:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .anyRequest().authenticated() ) .httpBasic(basic -> basic .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) ) // 禁用默认表单登录重定向 .formLogin(form -> form.disable()); return http.build(); } }
2. Android端优化:限制重试次数+处理401错误
即使Spring端调整,Android端也需要做好错误兜底:
- 修改Authenticator限制重试次数:
private fun getBasicAuth(username: String?, password: String?): Authenticator? = object : Authenticator { override fun authenticate(route: Route?, response: okhttp3.Response): Request? { // 仅重试1次,避免无限循环 val retryCount = response.request().header("Retry-Count")?.toInt() ?: 0 if (retryCount >= 1) { return null // 停止重试 } return response.request() .newBuilder() .addHeader("Authorization", Credentials.basic(username, password)) .addHeader("Retry-Count", (retryCount + 1).toString()) .build() } }
- 在调用时处理401状态:
lifecycleScope.launch { val response = ApiAdapter.apiClient.getFirstInventoryItem() if (response.isSuccessful) { val item = response.body() binding?.tvTest?.text = item.toString() } else { when (response.code()) { 401 -> binding?.tvTest?.text = "用户名或密码错误" else -> binding?.tvTest?.text = "请求失败,错误码:${response.code()}" } } }
针对问题2:处理其他请求错误
1. Spring端:全局异常处理
添加全局异常处理器,返回标准化错误响应:
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleException(Exception e) { ErrorResponse error = new ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), e.getMessage()); return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR); } @ExceptionHandler(AccessDeniedException.class) public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException e) { ErrorResponse error = new ErrorResponse(HttpStatus.FORBIDDEN.value(), "权限不足"); return new ResponseEntity<>(error, HttpStatus.FORBIDDEN); } // 自定义错误响应实体 public static class ErrorResponse { private int code; private String message; public ErrorResponse(int code, String message) { this.code = code; this.message = message; } // getter和setter方法 public int getCode() { return code; } public void setCode(int code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } } }
2. Android端:完善错误处理逻辑
- 捕获网络、解析等异常,分场景处理:
lifecycleScope.launch { try { val response = ApiAdapter.apiClient.getFirstInventoryItem() if (response.isSuccessful) { val item = response.body() binding?.tvTest?.text = item.toString() } else { handleHttpError(response.code()) } } catch (e: IOException) { binding?.tvTest?.text = "网络连接失败,请检查网络" } catch (e: Exception) { binding?.tvTest?.text = "请求处理失败:${e.message}" } } private fun handleHttpError(code: Int) { val errorMsg = when(code) { 403 -> "没有权限访问该资源" 404 -> "请求的资源不存在" 500 -> "服务器内部错误" else -> "请求失败,错误码:$code" } binding?.tvTest?.text = errorMsg }
- 可选:添加日志拦截器方便调试:
private fun getHttpClient(user: String, pass: String): OkHttpClient = OkHttpClient .Builder() .authenticator(getBasicAuth(user,pass)) .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) .build()
内容的提问来源于stack exchange,提问作者Kamil

