Skip to content

Commit 0923d59

Browse files
committed
fix(api): handle 403 subscription errors without retrying
Problem: - Ollama returns 403 with plain string errors (not hash errors) - ResponseHandler was only checking hash errors for subscription keywords - Even when permanent failure was detected, WorkflowOrchestrator still counted it against session error budget and retried - User saw 'Maximum retries exceeded: Authentication token refreshed' instead of the actual subscription error Solution: - ResponseHandler: Check $error directly for subscription keywords (handles both hash and plain string error formats) - WorkflowOrchestrator: Return immediately on error_type=auth_failed (bypasses session error budget and retry logic) Testing: - All 29 unit tests pass - Integration test shows correct error returned on first attempt
1 parent 53d499c commit 0923d59

3 files changed

Lines changed: 186 additions & 17 deletions

File tree

lib/CLIO/Core/API/ResponseHandler.pm

Lines changed: 85 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

lib/CLIO/Core/WorkflowOrchestrator.pm

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2065,12 +2065,12 @@ sub _handle_api_error {
20652065
return 'retry';
20662066
}
20672067

2068-
# ── Non-retryable rate limit handling (weekly/monthly limits) ─────
2068+
# ── Non-retryable rate limit handling (weekly/monthly limits and Copilot session limits) ─────
20692069
# These don't reset quickly - return immediately with error, don't retry
20702070
if (defined($api_response->{error_type}) && $api_response->{error_type} eq 'rate_limit' && !$api_response->{retryable}) {
20712071
my $rl_code = $api_response->{rate_limit_code} // '';
2072-
if ($rl_code =~ /user_weekly_rate_limited|user_monthly_rate_limited/i) {
2073-
log_info('WorkflowOrchestrator', "Weekly/monthly rate limit detected ($rl_code) - returning error without retry");
2072+
if ($rl_code =~ /user_weekly_rate_limited|user_monthly_rate_limited|copilot_session_limit/i) {
2073+
log_info('WorkflowOrchestrator', "Non-retryable rate limit detected ($rl_code) - returning error without retry");
20742074
return {
20752075
success => 0,
20762076
error => $api_response->{error},
@@ -2081,6 +2081,18 @@ sub _handle_api_error {
20812081
}
20822082
}
20832083

2084+
# ── Non-retryable auth failures (403 subscription errors) ──────────────────────────────────
2085+
# These are permanent failures - token recovery won't help
2086+
if (defined($api_response->{error_type}) && $api_response->{error_type} eq 'auth_failed') {
2087+
log_info('WorkflowOrchestrator', "Permanent auth failure detected - returning error immediately");
2088+
return {
2089+
success => 0,
2090+
error => $api_response->{error},
2091+
iterations => $iteration,
2092+
tool_calls_made => $tool_calls_made,
2093+
};
2094+
}
2095+
20842096
# ── Non-retryable errors ──────────────────────────────────────────
20852097
$$retry_count_ref = 0;
20862098

tests/unit/test_response_handler.pl

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,90 @@
324324
is($result->{error_type}, 'connection_error', 'Error type is connection_error');
325325
};
326326

327+
# =============================================================================
328+
# 401/403 handling tests
329+
# =============================================================================
330+
331+
subtest 'handle_error_response - 403 subscription required (permanent failure)' => sub {
332+
my $handler = CLIO::Core::API::ResponseHandler->new();
333+
my $resp = MockResponse->new(
334+
code => 403,
335+
status_line => '403 Forbidden',
336+
content => '{"error":{"message":"this model requires a subscription, upgrade for access: https://ollama.com/upgrade","code":"subscription_required"}}',
337+
);
338+
339+
# No recovery callback - should return immediately with the subscription error
340+
my $result = $handler->handle_error_response($resp, '{}', 0);
341+
is($result->{retryable}, 0, 'Subscription 403 is NOT retryable');
342+
is($result->{error_type}, 'auth_failed', 'Error type is auth_failed');
343+
like($result->{error}, qr/subscription|upgrade/i, 'Error contains subscription message');
344+
};
345+
346+
subtest 'handle_error_response - 403 plain string error (ollama style)' => sub {
347+
my $handler = CLIO::Core::API::ResponseHandler->new();
348+
# Simulate Ollama's plain string error - error_obj is just a string, not a hash
349+
my $resp = MockResponse->new(
350+
code => 403,
351+
status_line => '403 Forbidden',
352+
content => '{"error":"this model requires a subscription, upgrade for access: https://ollama.com/upgrade"}',
353+
);
354+
355+
my $result = $handler->handle_error_response($resp, '{}', 0);
356+
is($result->{retryable}, 0, 'Subscription 403 is NOT retryable (plain string error)');
357+
is($result->{error_type}, 'auth_failed', 'Error type is auth_failed');
358+
like($result->{error}, qr/subscription|upgrade/i, 'Error contains subscription message');
359+
};
360+
361+
subtest 'handle_error_response - 403 with recovery callback (transient auth issue)' => sub {
362+
my $handler = CLIO::Core::API::ResponseHandler->new();
363+
my $resp = MockResponse->new(
364+
code => 403,
365+
status_line => '403 Forbidden',
366+
content => '{"error":{"message":"Token invalid","code":"token_invalid"}}',
367+
);
368+
369+
# With recovery callback that succeeds
370+
my $result = $handler->handle_error_response($resp, '{}', 0,
371+
attempt_token_recovery => sub { return 1; }
372+
);
373+
is($result->{retryable}, 1, '403 with recovery is retryable');
374+
is($result->{error_type}, 'auth_recovered', 'Error type is auth_recovered');
375+
like($result->{error}, qr/refreshed/i, 'Error says token refreshed');
376+
};
377+
378+
subtest 'handle_error_response - 401 with recovery (invalid token)' => sub {
379+
my $handler = CLIO::Core::API::ResponseHandler->new();
380+
my $resp = MockResponse->new(
381+
code => 401,
382+
status_line => '401 Unauthorized',
383+
content => '{"error":{"message":"Invalid token"}}',
384+
);
385+
386+
# With recovery callback that succeeds
387+
my $result = $handler->handle_error_response($resp, '{}', 0,
388+
attempt_token_recovery => sub { return 1; }
389+
);
390+
is($result->{retryable}, 1, '401 with recovery is retryable');
391+
is($result->{error_type}, 'auth_recovered', 'Error type is auth_recovered');
392+
};
393+
394+
# =============================================================================
395+
# Copilot session rate limit tests
396+
# =============================================================================
397+
398+
subtest 'handle_error_response - Copilot session rate limit message' => sub {
399+
my $handler = CLIO::Core::API::ResponseHandler->new();
400+
my $resp = MockResponse->new(
401+
code => 429,
402+
status_line => '429 Too Many Requests',
403+
content => '{"error":{"message":"You have used 51% of your session rate limit","code":"session_limit"}}',
404+
);
405+
406+
my $result = $handler->handle_error_response($resp, '{}', 0);
407+
is($result->{retryable}, 0, 'Copilot session limit is NOT retryable');
408+
is($result->{error_type}, 'rate_limit', 'Error type is rate_limit');
409+
is($result->{rate_limit_code}, 'copilot_session_limit', 'Rate limit code is copilot_session_limit');
410+
like($result->{error}, qr/51%.*session.*rate.*limit/i, 'Error contains the quota message');
411+
};
412+
327413
done_testing();

0 commit comments

Comments
 (0)