@@ -403,6 +403,18 @@ sub handle_error_response {
403403 };
404404 my $user_message = _get_rate_limit_user_message($rate_limit_info );
405405
406+ # Also check for Copilot-style quota messages ("You've used X% of your session rate limit")
407+ # These come in error_obj.message or error_obj.reason and should be preserved as system_message
408+ if (!$user_message && $error_obj && ref ($error_obj ) eq ' HASH' ) {
409+ my $quota_msg = $error_obj -> {message } // $error_obj -> {reason } // ' ' ;
410+ # Match both "you've used" and "you have used" patterns
411+ if ($quota_msg =~ / you(?:'ve| have) used \d +%? of your? (session )?rate limit/i ||
412+ $quota_msg =~ / percent_remaining/i ) {
413+ $user_message = $quota_msg ;
414+ log_info(' ResponseHandler' , " Captured Copilot quota message: $quota_msg " );
415+ }
416+ }
417+
406418 # Weekly/monthly limits don't reset quickly - don't use misleading retry_after header
407419 # The header might say "retry in 1 second" but the actual limit takes days to reset
408420 log_debug(' ResponseHandler' , " Rate limit check: detected_code=$detected_rate_limit_code , retry_after=$retry_after , reset_ts=$reset_timestamp " );
@@ -640,6 +652,27 @@ sub handle_error_response {
640652 $retry_info = sprintf (" API rate limit exceeded. Retrying in %d seconds." , $retry_after );
641653 $error = $retry_info ;
642654 }
655+
656+ # Handle Copilot-style quota messages ("You've used X% of your session rate limit")
657+ # These are non-retryable - the user needs to wait for reset, can't fix with retry
658+ if ($user_message && $user_message =~ / you(?:'ve| have) used \d +%? of your? (session )?rate limit/i ) {
659+ $is_retryable_error = 0;
660+ $retryable = 0;
661+ $retry_after = 0;
662+ $error_type = ' rate_limit' ;
663+ $error = $user_message ; # Return the message directly, no "Retrying in X seconds"
664+ log_info(' ResponseHandler' , " Copilot session rate limit detected (non-retryable): $user_message " );
665+
666+ my $copilot_result = { success => 0, error => $error , _error => $error };
667+ $copilot_result -> {retryable } = 0;
668+ $copilot_result -> {retry_after } = 0;
669+ $copilot_result -> {error_type } = ' rate_limit' ;
670+ $copilot_result -> {rate_limit_code } = ' copilot_session_limit' ;
671+ $copilot_result -> {system_message } = $user_message ;
672+ $copilot_result -> {error_obj } = $error_obj if $error_obj ;
673+ log_debug(' ResponseHandler' , " Final error being returned: $error " );
674+ return $copilot_result ;
675+ }
643676 }
644677 # Handle quota exceeded errors (non-retryable - user must take action)
645678 # Detected via semantic codes in error body, not HTTP status
@@ -659,25 +692,63 @@ sub handle_error_response {
659692 log_info(' ResponseHandler' , " Quota exceeded (code=$error_obj ->{code}): $user_message " );
660693 }
661694 # Handle authentication failures (401, 403)
695+ # RFC 9110: 401 = "I don't know you" (invalid credentials), 403 = "I know you but you're not allowed"
696+ # We treat these differently:
697+ # - 401: Token is invalid, recovery makes sense
698+ # - 403: Check if it's a permanent failure (subscription required, model unavailable) before recovering
662699 elsif ($status == 401 || $status == 403) {
663- log_info(' ResponseHandler' , " Authentication error ($status ), attempting token recovery" );
700+ # For 403, check if the error message indicates a permanent failure that token recovery won't fix
701+ my $is_permanent_auth_failure = 0;
702+ my $original_error_msg = $error ; # Preserve original error for permanent failures
703+
704+ if ($status == 403) {
705+ # Check for subscription/upgrade/payment required errors
706+ # These are permanent - retrying won't help
707+ # Handle both hash errors ({message => "...", code => "..."}) and plain string errors
708+ my $err_msg = ' ' ;
709+ if (ref ($error_obj ) eq ' HASH' ) {
710+ $err_msg = $error_obj -> {message } // ' ' ;
711+ } elsif (!ref ($error_obj )) {
712+ # Plain string error - use the error string directly
713+ $err_msg = " $error_obj " ;
714+ }
664715
665- my $recovered = 0;
666- if ($attempt_token_recovery ) {
667- $recovered = $attempt_token_recovery -> ();
716+ if ($err_msg =~ / subscription|upgrade|paid|requires? (a |the )?(subscription|model|plan)/i ) {
717+ $is_permanent_auth_failure = 1;
718+ log_info(' ResponseHandler' , " 403 permanent auth failure detected (subscription/upgrade required): $err_msg " );
719+ }
668720 }
669721
670- if ($recovered ) {
671- $is_retryable_error = 1;
672- $retryable = 1;
673- $retry_after = 1;
674- $error_type = ' auth_recovered' ;
675- $retry_info = " Authentication token refreshed. Retrying request..." ;
676- $error = $retry_info ;
677- } else {
678- $error = " Authentication failed (HTTP $status ). Your token may have expired or been revoked. "
679- . " Please run /api logout then /api login to re-authenticate." ;
722+ if ($is_permanent_auth_failure ) {
723+ # Permanent failure - don't attempt recovery, just report the original error
724+ $is_retryable_error = 0;
725+ $retryable = 0;
680726 $error_type = ' auth_failed' ;
727+ # Preserve the actual provider error message
728+ $error = $original_error_msg ;
729+ log_info(' ResponseHandler' , " Returning permanent 403 error without recovery attempt" );
730+ }
731+ else {
732+ # Potentially transient auth failure (401, or 403 without subscription keywords)
733+ log_info(' ResponseHandler' , " Authentication error ($status ), attempting token recovery" );
734+
735+ my $recovered = 0;
736+ if ($attempt_token_recovery ) {
737+ $recovered = $attempt_token_recovery -> ();
738+ }
739+
740+ if ($recovered ) {
741+ $is_retryable_error = 1;
742+ $retryable = 1;
743+ $retry_after = 1;
744+ $error_type = ' auth_recovered' ;
745+ $retry_info = " Authentication token refreshed. Retrying request..." ;
746+ $error = $retry_info ;
747+ } else {
748+ $error = " Authentication failed (HTTP $status ). Your token may have expired or been revoked. "
749+ . " Please run /api logout then /api login to re-authenticate." ;
750+ $error_type = ' auth_failed' ;
751+ }
681752 }
682753 }
683754 # Handle transient server errors (5xx except 599 which is handled as connection_error)
0 commit comments