33from __future__ import annotations
44
55from typing import Any
6- from urllib .parse import urlparse
6+ from urllib .parse import quote , urlparse
77
88import httpx
99
1010from ._version import __version__
11- from .errors import parse_error
11+ from .errors import HawkConnectionError , parse_error
1212from .retry import DEFAULT_RETRY_CONFIG , RetryConfig , with_retry , with_retry_sync
1313from .streaming import AsyncStreamReader , StreamReader
1414from .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+
4361def _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 )
0 commit comments