Skip to content

Commit a320008

Browse files
leliaclaude
andcommitted
fix(gitlab): make the authentication fallback actually run
_get_auth_headers guesses between Bearer and PRIVATE-TOKEN from the shape of the token, and retries once under the other scheme on a 401 so a wrong guess does not fail the run. That retry has never executed. Three things had to line up and none of them did: - The retry caught requests.exceptions.HTTPError, but CliClient translates every requests error into APIFailure before it gets there. - CliClient discarded the HTTP status, so even a caught failure could not be identified as a 401. is_transient_error was equally blind for the same reason. - There are two APIFailure classes -- the CLI's own and the SDK's -- and they were independent Exception subclasses. CliClient raises the CLI's; every handler in socketsecurity.core imports the SDK's. None of those eight handlers has ever caught a CliClient failure. The CLI's APIFailure now subclasses the SDK's, so a handler written against either catches both, and the status code travels with the exception. The two tests covering the fallback were skipped rather than fixed, with a reason that no longer described the failure -- the constructor they blamed is used by the two passing tests in the same file. They now drive the exception the way CliClient actually raises it, and fail if any of the three links above is broken again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b8502bb commit a320008

6 files changed

Lines changed: 134 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@
6666
access and honors the command with a warning where the provider cannot report it,
6767
`strict` rejects it in that case instead, and `off` performs no check.
6868

69+
### Fixed: GitLab authentication fallback never ran
70+
71+
- When a GitLab token's type cannot be inferred from its shape, the CLI guesses
72+
between Bearer and PRIVATE-TOKEN and retries once under the other scheme on a
73+
401. That retry never happened: the retry caught `requests.exceptions.HTTPError`,
74+
but the HTTP client translates every request error into `APIFailure` first, so a
75+
misclassified token failed the run instead of falling back.
76+
- API failures raised by the CLI's HTTP client now carry their HTTP status code.
77+
Without it a 401 was indistinguishable from any other failure, and
78+
`is_transient_error` could not classify one either.
79+
- The CLI's `APIFailure` now subclasses the SDK exception of the same name. They
80+
were independent types, so an `except APIFailure` importing the SDK's — which is
81+
what every handler in `socketsecurity.core` does — did not catch a failure raised
82+
by the HTTP client.
83+
6984
### Fixed: pull request and merge request comment accuracy
7085

7186
- Per-alert ignore instructions now use ecosystem-qualified package names and

socketsecurity/core/cli_client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ def request(
5656

5757
except requests.exceptions.RequestException as e:
5858
logger.error(f"API request failed: {str(e)}")
59-
raise APIFailure(f"Request failed: {str(e)}")
59+
# Carry the status forward. Callers that need to react to a specific
60+
# code -- the GitLab auth fallback to the other token scheme, and
61+
# APIFailure.is_transient_error -- have no other way to recover it
62+
# once the requests exception has been translated.
63+
status_code = e.response.status_code if e.response is not None else None
64+
raise APIFailure(f"Request failed: {str(e)}", status_code=status_code) from e
6065

6166
def post_telemetry_events(self, org_slug: str, events: List[Dict]) -> None:
6267
"""Post telemetry events one at a time to the v0 telemetry API. Fire-and-forget — logs errors but never raises."""

socketsecurity/core/exceptions.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from socketdev.exceptions import APIFailure as SdkAPIFailure
2+
13
__all__ = [
24
"APIFailure",
35
"APIKeyMissing",
@@ -18,8 +20,15 @@ class APIKeyMissing(Exception):
1820
pass
1921

2022

21-
class APIFailure(Exception):
22-
"""Raised when there is an error using the API"""
23+
class APIFailure(SdkAPIFailure):
24+
"""Raised when there is an error using the API.
25+
26+
Subclasses the SDK's exception of the same name so a handler written against
27+
either one catches both. They were independent Exception subclasses, so an
28+
``except APIFailure`` importing the SDK's -- which every handler in
29+
socketsecurity.core does -- silently let a CliClient failure through, and the
30+
status code the SDK class carries was unavailable to anything raised here.
31+
"""
2332
pass
2433

2534

socketsecurity/core/scm/gitlab.py

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Optional
66

77
import requests
8+
from socketdev.exceptions import APIFailure
89

910
from socketsecurity import USER_AGENT
1011
from socketsecurity.core import log
@@ -149,32 +150,31 @@ def __init__(
149150
self._member_lookup_attempted = False
150151

151152
def _request_with_fallback(self, **kwargs):
152-
"""
153-
Make a request with automatic fallback between Bearer and PRIVATE-TOKEN authentication.
154-
This provides robustness when the initial token type detection is incorrect.
153+
"""Request with one retry under the other GitLab auth scheme on a 401.
154+
155+
_get_auth_headers guesses between Bearer and PRIVATE-TOKEN from the shape of
156+
the token, and the guess can be wrong for tokens that do not match a known
157+
pattern. Rather than fail the run, try the other scheme once.
158+
159+
Catches APIFailure, not requests.exceptions.HTTPError: CliClient translates
160+
every requests error into APIFailure, which does not inherit from HTTPError,
161+
so catching the latter here never fired and the fallback never ran.
155162
"""
156163
try:
157-
# Try the initial request with the configured headers
158164
return self.client.request(**kwargs)
159-
except requests.exceptions.HTTPError as e:
160-
# Check if this is an authentication error (401)
161-
if e.response and e.response.status_code == 401:
162-
log.debug("Authentication failed with initial headers, trying fallback method")
163-
164-
# Determine the fallback headers
165-
original_headers = kwargs.get('headers', self.config.headers)
166-
fallback_headers = self._get_fallback_headers(original_headers)
167-
168-
if fallback_headers and fallback_headers != original_headers:
169-
log.debug("Retrying request with fallback authentication method")
170-
kwargs['headers'] = fallback_headers
171-
return self.client.request(**kwargs)
172-
173-
# Re-raise the original exception if it's not an auth error or fallback failed
174-
raise
175-
except Exception:
176-
# Handle other types of exceptions that don't have response attribute
177-
raise
165+
except APIFailure as error:
166+
if error.status_code != 401:
167+
raise
168+
169+
log.debug("Authentication failed with initial headers, trying fallback method")
170+
original_headers = kwargs.get('headers', self.config.headers)
171+
fallback_headers = self._get_fallback_headers(original_headers)
172+
if not fallback_headers or fallback_headers == original_headers:
173+
raise
174+
175+
log.debug("Retrying request with fallback authentication method")
176+
kwargs['headers'] = fallback_headers
177+
return self.client.request(**kwargs)
178178

179179
def _get_fallback_headers(self, original_headers: dict) -> dict:
180180
"""

tests/unit/test_client.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,56 @@ def test_post_telemetry_events_continues_on_failure(client):
174174
client.post_telemetry_events("test-org", events)
175175

176176
assert mock_request.call_count == 2
177+
178+
179+
def test_request_preserves_the_http_status_on_failure():
180+
"""The status is the only thing that survives translation to APIFailure.
181+
182+
Callers that must react to a specific code -- the GitLab auth fallback on a
183+
401, and APIFailure.is_transient_error -- have no other way to recover it once
184+
the requests exception is gone.
185+
"""
186+
config = SocketConfig(api_key="test_key")
187+
client = CliClient(config)
188+
189+
with patch('requests.request') as mock_request:
190+
mock_response = Mock()
191+
mock_response.status_code = 401
192+
error = requests.exceptions.HTTPError("401 Client Error")
193+
error.response = mock_response
194+
mock_response.raise_for_status.side_effect = error
195+
mock_request.return_value = mock_response
196+
197+
with pytest.raises(APIFailure) as exc_info:
198+
client.request("test/path")
199+
200+
assert exc_info.value.status_code == 401
201+
202+
203+
def test_request_tolerates_a_failure_with_no_response():
204+
"""A connection error never reached a server, so there is no status to carry."""
205+
config = SocketConfig(api_key="test_key")
206+
client = CliClient(config)
207+
208+
with patch('requests.request') as mock_request:
209+
mock_request.side_effect = requests.exceptions.ConnectionError("no route")
210+
211+
with pytest.raises(APIFailure) as exc_info:
212+
client.request("test/path")
213+
214+
assert exc_info.value.status_code is None
215+
216+
217+
def test_a_handler_written_against_the_sdk_exception_catches_client_failures():
218+
"""socketsecurity.core imports the SDK's APIFailure in every handler, while
219+
CliClient raises the CLI's own. They must not be independent types."""
220+
from socketdev.exceptions import APIFailure as SdkAPIFailure
221+
222+
config = SocketConfig(api_key="test_key")
223+
client = CliClient(config)
224+
225+
with patch('requests.request') as mock_request:
226+
mock_request.side_effect = requests.exceptions.ConnectionError("no route")
227+
228+
with pytest.raises(SdkAPIFailure):
229+
client.request("test/path")

tests/unit/test_gitlab_auth_fallback.py

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
1-
"""Integration test demonstrating GitLab authentication fallback"""
1+
"""GitLab authentication fallback.
2+
3+
_get_auth_headers guesses between Bearer and PRIVATE-TOKEN from the shape of the
4+
token. When the guess is wrong the first request comes back 401, and the CLI
5+
retries once under the other scheme rather than failing the run.
6+
7+
The retry hinges on which exception type it catches: CliClient translates every
8+
requests error into APIFailure, which is not an HTTPError, so these tests drive
9+
the failure the way CliClient actually raises it.
10+
"""
211
import os
312
from unittest.mock import MagicMock, patch
413

514
import pytest
15+
from socketdev.exceptions import APIFailure
616

717
from socketsecurity.core.scm.gitlab import Gitlab
818
from socketsecurity.socketcli import CliClient
919

1020

21+
def _auth_failure() -> APIFailure:
22+
"""The exception CliClient raises for a 401, with the status preserved."""
23+
return APIFailure("Request failed: 401 Client Error", status_code=401)
24+
25+
1126
class TestGitlabAuthFallback:
1227
"""Test GitLab authentication fallback mechanism"""
1328

@@ -18,21 +33,13 @@ class TestGitlabAuthFallback:
1833
'CI_MERGE_REQUEST_IID': '123',
1934
'CI_MERGE_REQUEST_PROJECT_ID': '456'
2035
})
21-
@pytest.mark.skip(reason="Gitlab constructor does not accept client kwarg; needs rework to match current implementation")
2236
def test_fallback_from_private_token_to_bearer(self):
2337
"""Test fallback from PRIVATE-TOKEN to Bearer authentication"""
2438
# Create a mock client that simulates auth failure then success
2539
mock_client = MagicMock(spec=CliClient)
2640

27-
# First call (with PRIVATE-TOKEN) fails with 401
28-
auth_error = Exception()
29-
auth_error.response = MagicMock()
30-
auth_error.response.status_code = 401
31-
32-
# Second call (with Bearer) succeeds
33-
success_response = {'notes': []}
34-
35-
mock_client.request.side_effect = [auth_error, success_response]
41+
# First call (with PRIVATE-TOKEN) fails with 401, second (Bearer) succeeds
42+
mock_client.request.side_effect = [_auth_failure(), MagicMock(json=lambda: [])]
3643

3744
# Create GitLab instance with mock client
3845
gitlab = Gitlab(client=mock_client)
@@ -60,21 +67,13 @@ def test_fallback_from_private_token_to_bearer(self):
6067
'CI_MERGE_REQUEST_IID': '123',
6168
'CI_MERGE_REQUEST_PROJECT_ID': '456'
6269
})
63-
@pytest.mark.skip(reason="Gitlab constructor does not accept client kwarg; needs rework to match current implementation")
6470
def test_fallback_from_bearer_to_private_token(self):
6571
"""Test fallback from Bearer to PRIVATE-TOKEN authentication"""
6672
# Create a mock client that simulates auth failure then success
6773
mock_client = MagicMock(spec=CliClient)
6874

69-
# First call (with Bearer) fails with 401
70-
auth_error = Exception()
71-
auth_error.response = MagicMock()
72-
auth_error.response.status_code = 401
73-
74-
# Second call (with PRIVATE-TOKEN) succeeds
75-
success_response = {'notes': []}
76-
77-
mock_client.request.side_effect = [auth_error, success_response]
75+
# First call (with Bearer) fails with 401, second (PRIVATE-TOKEN) succeeds
76+
mock_client.request.side_effect = [_auth_failure(), MagicMock(json=lambda: [])]
7877

7978
# Create GitLab instance with mock client
8079
gitlab = Gitlab(client=mock_client)
@@ -107,18 +106,16 @@ def test_non_auth_error_not_retried(self):
107106
# Create a mock client that simulates a non-auth error
108107
mock_client = MagicMock(spec=CliClient)
109108

110-
# Simulate a 500 error (not auth-related)
111-
server_error = Exception()
112-
server_error.response = MagicMock()
113-
server_error.response.status_code = 500
114-
115-
mock_client.request.side_effect = server_error
109+
# A 500 is not recoverable by changing the auth scheme.
110+
mock_client.request.side_effect = APIFailure(
111+
"Request failed: 500 Server Error", status_code=500
112+
)
116113

117114
# Create GitLab instance with mock client
118115
gitlab = Gitlab(client=mock_client)
119116

120117
# This should NOT trigger the fallback mechanism
121-
with pytest.raises(Exception):
118+
with pytest.raises(APIFailure):
122119
gitlab.get_comments_for_pr()
123120

124121
# Verify only one request was made (no retry)
@@ -135,7 +132,7 @@ def test_successful_first_attempt_no_fallback(self):
135132
"""Test that successful requests don't trigger fallback"""
136133
# Create a mock client that succeeds on first try
137134
mock_client = MagicMock(spec=CliClient)
138-
mock_client.request.return_value = {'notes': []}
135+
mock_client.request.return_value = MagicMock(json=lambda: [])
139136

140137
# Create GitLab instance with mock client
141138
gitlab = Gitlab(client=mock_client)

0 commit comments

Comments
 (0)