Skip to content

Commit 70a84aa

Browse files
committed
Client, error handling, and streaming hardening pass
- Update AGENTS.md to the shared hawk-eco extension-authoring format
1 parent 8658af3 commit 70a84aa

7 files changed

Lines changed: 324 additions & 254 deletions

File tree

AGENTS.md

Lines changed: 138 additions & 189 deletions
Large diffs are not rendered by default.

src/hawk/client.py

Lines changed: 78 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
from __future__ import annotations
44

55
from typing import Any
6-
from urllib.parse import urlparse
6+
from urllib.parse import quote, urlparse
77

88
import httpx
99

1010
from ._version import __version__
11-
from .errors import parse_error
11+
from .errors import HawkConnectionError, parse_error
1212
from .retry import DEFAULT_RETRY_CONFIG, RetryConfig, with_retry, with_retry_sync
1313
from .streaming import AsyncStreamReader, StreamReader
1414
from .types import (
@@ -40,6 +40,24 @@ def _build_headers(api_key: str | None) -> dict[str, str]:
4040
return headers
4141

4242

43+
def _mask_api_key(api_key: str | None) -> str:
44+
"""Mask an API key for safe display in repr()/logs.
45+
46+
Only reveals a trailing suffix when the key is long enough that doing
47+
so doesn't expose the whole secret.
48+
"""
49+
if not api_key:
50+
return "None"
51+
if len(api_key) <= 8:
52+
return "***"
53+
return "***" + api_key[-4:]
54+
55+
56+
def _session_path(session_id: str) -> str:
57+
"""URL-encode a session_id before splicing it into a request path."""
58+
return quote(session_id, safe="")
59+
60+
4361
def _validate_base_url(url: str) -> str:
4462
"""Validate that base_url uses http/https and has a hostname."""
4563
parsed = urlparse(url)
@@ -86,7 +104,7 @@ def __init__(
86104
)
87105

88106
def __repr__(self) -> str:
89-
masked = "***" + self._api_key[-4:] if self._api_key else "None"
107+
masked = _mask_api_key(self._api_key)
90108
return f"HawkClient(base_url='{self._base_url}', api_key='{masked}')"
91109

92110
def __enter__(self) -> HawkClient:
@@ -101,7 +119,10 @@ def close(self) -> None:
101119

102120
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
103121
"""Make an HTTP request, raising typed errors on failure."""
104-
response = self._client.request(method, path, **kwargs)
122+
try:
123+
response = self._client.request(method, path, **kwargs)
124+
except httpx.RequestError as e:
125+
raise HawkConnectionError(f"request failed: {e}") from e
105126
if response.status_code >= 400:
106127
raise parse_error(response)
107128
return response
@@ -149,7 +170,7 @@ def _do() -> ChatResponse:
149170
)
150171
return ChatResponse.model_validate(resp.json())
151172

152-
return with_retry_sync(_do, self._retry_config)
173+
return with_retry_sync(_do, self._retry_config, idempotent=False)
153174

154175
def chat_stream(
155176
self,
@@ -178,29 +199,35 @@ def chat_stream(
178199
)
179200

180201
def _do() -> StreamReader:
181-
response = self._client.send(
182-
self._client.build_request(
183-
"POST",
184-
"/v1/chat",
185-
json=request.model_dump(exclude_none=True, by_alias=True),
186-
headers={"Accept": "text/event-stream"},
187-
),
188-
stream=True,
189-
)
202+
try:
203+
response = self._client.send(
204+
self._client.build_request(
205+
"POST",
206+
"/v1/chat",
207+
json=request.model_dump(exclude_none=True, by_alias=True),
208+
headers={"Accept": "text/event-stream"},
209+
),
210+
stream=True,
211+
)
212+
except httpx.RequestError as e:
213+
raise HawkConnectionError(f"request failed: {e}") from e
190214

191215
if response.status_code >= 400:
192-
response.read()
193-
raise parse_error(response)
216+
try:
217+
response.read()
218+
raise parse_error(response)
219+
finally:
220+
response.close()
194221

195222
return StreamReader(response)
196223

197-
return with_retry_sync(_do, self._retry_config)
224+
return with_retry_sync(_do, self._retry_config, idempotent=False)
198225

199226
def get_session(self, session_id: str) -> SessionDetail:
200227
"""Get a session by ID."""
201228

202229
def _do() -> SessionDetail:
203-
resp = self._request("GET", f"/v1/sessions/{session_id}")
230+
resp = self._request("GET", f"/v1/sessions/{_session_path(session_id)}")
204231
return SessionDetail.model_validate(resp.json())
205232

206233
return with_retry_sync(_do, self._retry_config)
@@ -222,7 +249,7 @@ def delete_session(self, session_id: str) -> None:
222249
"""Delete a session by ID."""
223250

224251
def _do() -> None:
225-
self._request("DELETE", f"/v1/sessions/{session_id}")
252+
self._request("DELETE", f"/v1/sessions/{_session_path(session_id)}")
226253

227254
return with_retry_sync(_do, self._retry_config)
228255

@@ -235,7 +262,9 @@ def _do() -> PaginatedResponse[Message]:
235262
params: dict[str, Any] = {"limit": limit}
236263
if offset > 0:
237264
params["offset"] = offset
238-
resp = self._request("GET", f"/v1/sessions/{session_id}/messages", params=params)
265+
resp = self._request(
266+
"GET", f"/v1/sessions/{_session_path(session_id)}/messages", params=params
267+
)
239268
return PaginatedResponse[Message].model_validate(resp.json())
240269

241270
return with_retry_sync(_do, self._retry_config)
@@ -286,7 +315,7 @@ def __init__(
286315
)
287316

288317
def __repr__(self) -> str:
289-
masked = "***" + self._api_key[-4:] if self._api_key else "None"
318+
masked = _mask_api_key(self._api_key)
290319
return f"AsyncHawkClient(base_url='{self._base_url}', api_key='{masked}')"
291320

292321
async def __aenter__(self) -> AsyncHawkClient:
@@ -301,7 +330,10 @@ async def close(self) -> None:
301330

302331
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
303332
"""Make an async HTTP request, raising typed errors on failure."""
304-
response = await self._client.request(method, path, **kwargs)
333+
try:
334+
response = await self._client.request(method, path, **kwargs)
335+
except httpx.RequestError as e:
336+
raise HawkConnectionError(f"request failed: {e}") from e
305337
if response.status_code >= 400:
306338
raise parse_error(response)
307339
return response
@@ -349,7 +381,7 @@ async def _do() -> ChatResponse:
349381
)
350382
return ChatResponse.model_validate(resp.json())
351383

352-
return await with_retry(_do, self._retry_config)
384+
return await with_retry(_do, self._retry_config, idempotent=False)
353385

354386
async def chat_stream(
355387
self,
@@ -378,29 +410,35 @@ async def chat_stream(
378410
)
379411

380412
async def _do() -> AsyncStreamReader:
381-
response = await self._client.send(
382-
self._client.build_request(
383-
"POST",
384-
"/v1/chat",
385-
json=request.model_dump(exclude_none=True, by_alias=True),
386-
headers={"Accept": "text/event-stream"},
387-
),
388-
stream=True,
389-
)
413+
try:
414+
response = await self._client.send(
415+
self._client.build_request(
416+
"POST",
417+
"/v1/chat",
418+
json=request.model_dump(exclude_none=True, by_alias=True),
419+
headers={"Accept": "text/event-stream"},
420+
),
421+
stream=True,
422+
)
423+
except httpx.RequestError as e:
424+
raise HawkConnectionError(f"request failed: {e}") from e
390425

391426
if response.status_code >= 400:
392-
await response.aread()
393-
raise parse_error(response)
427+
try:
428+
await response.aread()
429+
raise parse_error(response)
430+
finally:
431+
await response.aclose()
394432

395433
return AsyncStreamReader(response)
396434

397-
return await with_retry(_do, self._retry_config)
435+
return await with_retry(_do, self._retry_config, idempotent=False)
398436

399437
async def get_session(self, session_id: str) -> SessionDetail:
400438
"""Get a session by ID."""
401439

402440
async def _do() -> SessionDetail:
403-
resp = await self._request("GET", f"/v1/sessions/{session_id}")
441+
resp = await self._request("GET", f"/v1/sessions/{_session_path(session_id)}")
404442
return SessionDetail.model_validate(resp.json())
405443

406444
return await with_retry(_do, self._retry_config)
@@ -422,7 +460,7 @@ async def delete_session(self, session_id: str) -> None:
422460
"""Delete a session by ID."""
423461

424462
async def _do() -> None:
425-
await self._request("DELETE", f"/v1/sessions/{session_id}")
463+
await self._request("DELETE", f"/v1/sessions/{_session_path(session_id)}")
426464

427465
return await with_retry(_do, self._retry_config)
428466

@@ -435,7 +473,9 @@ async def _do() -> PaginatedResponse[Message]:
435473
params: dict[str, Any] = {"limit": limit}
436474
if offset > 0:
437475
params["offset"] = offset
438-
resp = await self._request("GET", f"/v1/sessions/{session_id}/messages", params=params)
476+
resp = await self._request(
477+
"GET", f"/v1/sessions/{_session_path(session_id)}/messages", params=params
478+
)
439479
return PaginatedResponse[Message].model_validate(resp.json())
440480

441481
return await with_retry(_do, self._retry_config)

src/hawk/errors.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ def __init__(
2727
super().__init__(f"hawk-sdk: {message} (status {status_code})")
2828

2929

30+
class HawkConnectionError(HawkAPIError):
31+
"""Transport-level failure (connection refused, timeout, DNS, etc.).
32+
33+
Raised in place of raw httpx exceptions so callers only ever need to
34+
catch HawkAPIError to handle all SDK failures, and so retry logic can
35+
treat transient connectivity failures the same as retryable HTTP status
36+
codes.
37+
"""
38+
39+
def __init__(self, message: str, **kwargs: Any) -> None:
40+
super().__init__(message, status_code=0, **kwargs)
41+
42+
3043
class BadRequestError(HawkAPIError):
3144
"""400 Bad Request."""
3245

src/hawk/evaluate.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,24 @@
2323
import statistics
2424
import time
2525
from dataclasses import dataclass, field
26-
from typing import Any, Callable
26+
from typing import TYPE_CHECKING, Any, Callable, Protocol
27+
28+
if TYPE_CHECKING:
29+
from collections.abc import Awaitable
30+
31+
32+
class SyncBenchAgent(Protocol):
33+
"""Structural type for the agent accepted by run_benchmark."""
34+
35+
def reset(self) -> None: ...
36+
def chat(self, prompt: str) -> Any: ...
37+
38+
39+
class AsyncBenchAgent(Protocol):
40+
"""Structural type for the agent accepted by run_benchmark_async."""
41+
42+
def reset(self) -> None: ...
43+
def chat(self, prompt: str) -> Awaitable[Any]: ...
2744

2845

2946
@dataclass
@@ -104,7 +121,7 @@ def summary(self) -> str:
104121

105122

106123
def run_benchmark(
107-
agent: Any,
124+
agent: SyncBenchAgent,
108125
tasks: list[EvalTask],
109126
*,
110127
runs: int = 1,
@@ -136,8 +153,6 @@ def run_benchmark(
136153
success = True
137154
if task.validate:
138155
success = task.validate(response)
139-
if not success:
140-
pass
141156

142157
results.results.append(
143158
EvalResult(
@@ -168,7 +183,7 @@ def run_benchmark(
168183

169184

170185
async def run_benchmark_async(
171-
agent: Any,
186+
agent: AsyncBenchAgent,
172187
tasks: list[EvalTask],
173188
*,
174189
runs: int = 1,
@@ -190,8 +205,6 @@ async def run_benchmark_async(
190205
success = True
191206
if task.validate:
192207
success = task.validate(response)
193-
if not success:
194-
pass
195208

196209
results.results.append(
197210
EvalResult(

0 commit comments

Comments
 (0)