Skip to content

Commit bb7ebcb

Browse files
committed
feat(auth): validate credential before the first request
Raise TypeError from _validate_headers when neither pat, credential, nor QODER_PAT resolves an Authorization header, rather than sending a gateway-bypass request. A configured dynamic credential is allowed since it injects auth later in the send pipeline. Mirrors anthropic's request-time validation and the Go/TS SDKs.
1 parent 784c952 commit bb7ebcb

4 files changed

Lines changed: 36 additions & 8 deletions

File tree

src/qca/common/_base_client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,17 @@ def _raw_response_view(self, *, streaming: bool = False) -> Self:
118118
def is_closed(self) -> bool:
119119
return self._client.is_closed
120120

121+
def _validate_headers(self, headers: httpx.Headers) -> None:
122+
if headers.get("Authorization"):
123+
return
124+
if self.credential is not None:
125+
return
126+
127+
raise TypeError(
128+
"Could not resolve authentication method. Expected one of pat or credential to be set, "
129+
"or the QODER_PAT environment variable to be configured."
130+
)
131+
121132
def _headers(self, options: dict[str, Any], token: str | None) -> httpx.Headers:
122133
headers = httpx.Headers(
123134
{
@@ -132,6 +143,7 @@ def _headers(self, options: dict[str, Any], token: str | None) -> httpx.Headers:
132143
for source in (self.default_headers, options.get("headers", {})):
133144
for key, value in source.items():
134145
headers[key] = ",".join(str(v) for v in value) if isinstance(value, (list, tuple)) else str(value)
146+
self._validate_headers(headers)
135147
return headers
136148

137149
def _request_args(

tests/test_client.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ def make_client(handler, cls=Forward, **kwargs):
4242
)
4343

4444

45+
@pytest.mark.parametrize("cls,resource", [(Forward, "templates"), (Managed, "agents")])
46+
def test_missing_credential_raises_on_first_request(monkeypatch, cls, resource):
47+
monkeypatch.delenv("QODER_PAT", raising=False)
48+
# Construction succeeds; the error surfaces when we try to build the request.
49+
with cls(http_client=httpx.Client(transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"data": [], "has_more": False})))) as client:
50+
with pytest.raises(TypeError, match="Could not resolve authentication method"):
51+
getattr(client, resource).list()
52+
# An explicit PAT lets the same call go through.
53+
with cls(pat="explicit", http_client=httpx.Client(transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"data": [], "has_more": False})))) as client:
54+
getattr(client, resource).list()
55+
56+
4557
@pytest.mark.parametrize(
4658
"cls,env,suffix", [(Forward, "QODER_FORWARD_BASE_URL", "forward"), (Managed, "QODER_MANAGED_BASE_URL", "cloud")]
4759
)
@@ -416,7 +428,7 @@ async def handle(request):
416428
await asyncio.wait_for(both.wait(), timeout=1)
417429
return httpx.Response(200, json={"id": "identity", "data": []})
418430

419-
async with AsyncForward(http_client=httpx.AsyncClient(transport=httpx.MockTransport(handle))) as client:
431+
async with AsyncForward(pat="test", http_client=httpx.AsyncClient(transport=httpx.MockTransport(handle))) as client:
420432
first, second = await asyncio.gather(client.identities.retrieve("first"), client.identities.retrieve("second"))
421433
assert first.id == second.id == "identity"
422434
raw = await client.identities.with_raw_response.retrieve("third")
@@ -483,6 +495,7 @@ async def aclose(self):
483495

484496
body = Body()
485497
async with AsyncForward(
498+
pat="test",
486499
http_client=httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=body)))
487500
) as client:
488501
async with client.models.with_streaming_response.list() as response:

tests/test_pagination.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def handle(request):
1919
200, json={"data": [{"id": str(index)}], "has_more": index == 1, "first_id": "first", "last_id": "last"}
2020
)
2121

22-
with Forward(http_client=httpx.Client(transport=httpx.MockTransport(handle))) as client:
22+
with Forward(pat="test", http_client=httpx.Client(transport=httpx.MockTransport(handle))) as client:
2323
args = {"extra_query": {"name": "filter"}, **({"before_id": "start"} if backward else {})}
2424
page = client.templates.list(**args)
2525
assert [item.id for item in page] == ["1", "2"]
@@ -38,13 +38,14 @@ def handle(request):
3838
return httpx.Response(200, json={"data": [{"id": "two"}], "has_more": False})
3939

4040
with Managed(
41-
default_query={"after_id": "old"}, http_client=httpx.Client(transport=httpx.MockTransport(handle))
41+
pat="test", default_query={"after_id": "old"}, http_client=httpx.Client(transport=httpx.MockTransport(handle))
4242
) as client:
4343
assert [item.id for item in client.agents.list()] == ["two"]
4444

4545

4646
def test_repeated_pagination_cursor_raises_instead_of_looping():
4747
with Managed(
48+
pat="test",
4849
http_client=httpx.Client(
4950
transport=httpx.MockTransport(
5051
lambda _: httpx.Response(200, json={"data": [{"id": "one"}], "next_page": "same", "has_more": True})
@@ -72,7 +73,7 @@ def handle(request):
7273
},
7374
)
7475

75-
async with cls(http_client=httpx.AsyncClient(transport=httpx.MockTransport(handle))) as client:
76+
async with cls(pat="test", http_client=httpx.AsyncClient(transport=httpx.MockTransport(handle))) as client:
7677
resource = client.agents if cls is AsyncManaged else client.templates
7778
page = await resource.list()
7879
assert page.data[0].id == "one"
@@ -87,7 +88,7 @@ def handle(request):
8788
200, json={"data": [{"id": "one" if more else "two"}], "last_id": "one", "has_more": more}
8889
)
8990

90-
with Forward(http_client=httpx.Client(transport=httpx.MockTransport(handle))) as client:
91+
with Forward(pat="test", http_client=httpx.Client(transport=httpx.MockTransport(handle))) as client:
9192
page = client.templates.with_raw_response.list().parse()
9293
assert [item.id for item in page] == ["one", "two"]
9394

@@ -98,7 +99,7 @@ def test_cursor_returning_to_an_earlier_value_raises_instead_of_looping():
9899
def handle(request):
99100
return httpx.Response(200, json={"data": [{"id": "item"}], "next_page": cursors.pop(0), "has_more": True})
100101

101-
with Managed(http_client=httpx.Client(transport=httpx.MockTransport(handle))) as client:
102+
with Managed(pat="test", http_client=httpx.Client(transport=httpx.MockTransport(handle))) as client:
102103
with pytest.raises(RuntimeError, match="cycle detected"):
103104
list(client.agents.list())
104105

@@ -110,7 +111,7 @@ def handle(request):
110111
200, json={"data": [{"id": "one" if first else "two"}], "next_page": "next" if first else None}
111112
)
112113

113-
with Managed(http_client=httpx.Client(transport=httpx.MockTransport(handle))) as client:
114+
with Managed(pat="test", http_client=httpx.Client(transport=httpx.MockTransport(handle))) as client:
114115
page = client.agents.list()
115116
assert page.has_next_page()
116117
last = page.get_next_page()

tests/test_streaming.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def handle(request):
5656
assert request.url.params.get_list("event_deltas[]") == ["agent.message"]
5757
return httpx.Response(200, stream=chunks, headers={"content-type": "text/event-stream"})
5858

59-
with Forward(http_client=httpx.Client(transport=httpx.MockTransport(handle))) as client:
59+
with Forward(pat="test", http_client=httpx.Client(transport=httpx.MockTransport(handle))) as client:
6060
with client.sessions.events.stream(
6161
"session", last_event_id="previous", event_deltas=["agent.message"]
6262
) as stream:
@@ -78,6 +78,7 @@ def handle(request):
7878
def test_stream_errors_close_response(body, error):
7979
chunks = Chunks(body.encode())
8080
with Forward(
81+
pat="test",
8182
http_client=httpx.Client(transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=chunks)))
8283
) as client:
8384
with pytest.raises(error):
@@ -98,6 +99,7 @@ async def aclose(self):
9899

99100
chunks = AsyncChunks()
100101
async with AsyncManaged(
102+
pat="test",
101103
http_client=httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=chunks)))
102104
) as client:
103105
async with await client.sessions.events.stream("session", extra_headers={"Last-Event-ID": "before"}) as stream:

0 commit comments

Comments
 (0)