meshapi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
meshapi/_http.py ADDED
@@ -0,0 +1,444 @@
1
+ """HTTP client (sync + async) with retry/backoff and SSE parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import math
8
+ import random
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, AsyncIterator, Dict, Iterator, Optional, Set, Type, TypeVar, Union
12
+
13
+ import httpx
14
+
15
+ from ._errors import MeshAPIError
16
+ from ._types import ChatCompletionChunk
17
+
18
+ T = TypeVar("T")
19
+
20
+ _RETRY_STATUS_CODES: Set[int] = {429, 502, 503, 504}
21
+ _DEFAULT_TIMEOUT = 60.0
22
+ _DEFAULT_MAX_RETRIES = 3
23
+ _BACKOFF_BASE_MS = 500
24
+ _BACKOFF_MAX_MS = 30_000
25
+
26
+ _SDK_VERSION_HEADER = "X-MeshAPI-SDK"
27
+ _SDK_VERSION_VALUE = "python/0.1.0"
28
+
29
+
30
+ @dataclass
31
+ class MeshAPIConfig:
32
+ base_url: str
33
+ token: str
34
+ timeout: float = _DEFAULT_TIMEOUT
35
+ max_retries: int = _DEFAULT_MAX_RETRIES
36
+ httpx_client: Optional[httpx.Client] = field(default=None, repr=False)
37
+ async_httpx_client: Optional[httpx.AsyncClient] = field(default=None, repr=False)
38
+
39
+ def __post_init__(self) -> None:
40
+ self.base_url = self.base_url.rstrip("/")
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # SSE helpers
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ _DONE_SENTINEL = object() # returned by _try_parse_sse_frame when [DONE] is seen
49
+
50
+
51
+ def _extract_sse_data(frame: str) -> Optional[str]:
52
+ data_lines = []
53
+ for line in frame.splitlines():
54
+ if line.startswith("data: "):
55
+ data_lines.append(line[len("data: "):])
56
+ if not data_lines:
57
+ return None
58
+ return "\n".join(data_lines)
59
+
60
+
61
+ def _try_parse_sse_frame(frame: str) -> "Optional[Union[ChatCompletionChunk, object]]":
62
+ """Parse one SSE frame string.
63
+
64
+ Returns:
65
+ ChatCompletionChunk on success
66
+ _DONE_SENTINEL when [DONE] is received (caller should stop iteration)
67
+ None for empty / comment-only frames
68
+ Raises:
69
+ MeshAPIError on mid-stream error frames
70
+ """
71
+ data_line = _extract_sse_data(frame)
72
+ if data_line is None or data_line.strip() == "":
73
+ return None
74
+ if data_line.strip() == "[DONE]":
75
+ return _DONE_SENTINEL
76
+ try:
77
+ parsed = json.loads(data_line)
78
+ except json.JSONDecodeError:
79
+ return None
80
+
81
+ if isinstance(parsed, dict) and "error" in parsed:
82
+ err = parsed["error"]
83
+ raise MeshAPIError(
84
+ err.get("message", "upstream error"),
85
+ status=0,
86
+ error_code=err.get("code", "upstream_error"),
87
+ request_id="",
88
+ )
89
+
90
+ return ChatCompletionChunk.model_validate(parsed)
91
+
92
+
93
+ def _try_parse_json_sse_frame(frame: str, model_cls: Type[T]) -> Optional[Union[T, object]]:
94
+ data_line = _extract_sse_data(frame)
95
+ if data_line is None or data_line.strip() == "":
96
+ return None
97
+ if data_line.strip() == "[DONE]":
98
+ return _DONE_SENTINEL
99
+ try:
100
+ parsed = json.loads(data_line)
101
+ except json.JSONDecodeError:
102
+ return None
103
+ if isinstance(parsed, dict) and "error" in parsed:
104
+ err = parsed["error"]
105
+ raise MeshAPIError(
106
+ err.get("message", "upstream error"),
107
+ status=0,
108
+ error_code=err.get("code", "upstream_error"),
109
+ request_id="",
110
+ )
111
+ return model_cls.model_validate(parsed)
112
+
113
+
114
+ def _iter_sse(response: httpx.Response) -> Iterator[ChatCompletionChunk]:
115
+ """Sync SSE iterator with remainder-buffer handling. Stops on [DONE]."""
116
+ remainder = ""
117
+ try:
118
+ for raw_bytes in response.iter_bytes():
119
+ try:
120
+ remainder += raw_bytes.decode("utf-8", errors="replace")
121
+ except Exception:
122
+ continue
123
+ frames = remainder.split("\n\n")
124
+ remainder = frames.pop()
125
+ for frame in frames:
126
+ if not frame.strip():
127
+ continue
128
+ result = _try_parse_sse_frame(frame)
129
+ if result is _DONE_SENTINEL:
130
+ return
131
+ if result is not None:
132
+ yield result # type: ignore[misc]
133
+ except httpx.RemoteProtocolError as exc:
134
+ raise MeshAPIError.stream_interrupted(str(exc)) from exc
135
+ except httpx.StreamError as exc:
136
+ raise MeshAPIError.stream_interrupted(str(exc)) from exc
137
+
138
+
139
+ def _iter_json_sse(response: httpx.Response, model_cls: Type[T]) -> Iterator[T]:
140
+ remainder = ""
141
+ try:
142
+ for raw_bytes in response.iter_bytes():
143
+ try:
144
+ remainder += raw_bytes.decode("utf-8", errors="replace")
145
+ except Exception:
146
+ continue
147
+ frames = remainder.split("\n\n")
148
+ remainder = frames.pop()
149
+ for frame in frames:
150
+ if not frame.strip():
151
+ continue
152
+ result = _try_parse_json_sse_frame(frame, model_cls)
153
+ if result is _DONE_SENTINEL:
154
+ return
155
+ if result is not None:
156
+ yield result
157
+ except httpx.RemoteProtocolError as exc:
158
+ raise MeshAPIError.stream_interrupted(str(exc)) from exc
159
+ except httpx.StreamError as exc:
160
+ raise MeshAPIError.stream_interrupted(str(exc)) from exc
161
+
162
+
163
+ async def _aiter_sse(response: httpx.Response) -> AsyncIterator[ChatCompletionChunk]:
164
+ """Async SSE iterator with remainder-buffer handling. Stops on [DONE]."""
165
+ remainder = ""
166
+ try:
167
+ async for raw_bytes in response.aiter_bytes():
168
+ try:
169
+ remainder += raw_bytes.decode("utf-8", errors="replace")
170
+ except Exception:
171
+ continue
172
+ frames = remainder.split("\n\n")
173
+ remainder = frames.pop()
174
+ for frame in frames:
175
+ if not frame.strip():
176
+ continue
177
+ result = _try_parse_sse_frame(frame)
178
+ if result is _DONE_SENTINEL:
179
+ return
180
+ if result is not None:
181
+ yield result # type: ignore[misc]
182
+ except httpx.RemoteProtocolError as exc:
183
+ raise MeshAPIError.stream_interrupted(str(exc)) from exc
184
+ except httpx.StreamError as exc:
185
+ raise MeshAPIError.stream_interrupted(str(exc)) from exc
186
+
187
+
188
+ async def _aiter_json_sse(response: httpx.Response, model_cls: Type[T]) -> AsyncIterator[T]:
189
+ remainder = ""
190
+ try:
191
+ async for raw_bytes in response.aiter_bytes():
192
+ try:
193
+ remainder += raw_bytes.decode("utf-8", errors="replace")
194
+ except Exception:
195
+ continue
196
+ frames = remainder.split("\n\n")
197
+ remainder = frames.pop()
198
+ for frame in frames:
199
+ if not frame.strip():
200
+ continue
201
+ result = _try_parse_json_sse_frame(frame, model_cls)
202
+ if result is _DONE_SENTINEL:
203
+ return
204
+ if result is not None:
205
+ yield result
206
+ except httpx.RemoteProtocolError as exc:
207
+ raise MeshAPIError.stream_interrupted(str(exc)) from exc
208
+ except httpx.StreamError as exc:
209
+ raise MeshAPIError.stream_interrupted(str(exc)) from exc
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Retry helpers
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def _compute_delay_s(attempt: int, retry_after: Optional[int]) -> float:
218
+ """Exponential backoff with jitter, capped at _BACKOFF_MAX_MS."""
219
+ if retry_after is not None:
220
+ base = retry_after * 1000
221
+ else:
222
+ base = _BACKOFF_BASE_MS * (2 ** attempt)
223
+ capped = min(base, _BACKOFF_MAX_MS)
224
+ jittered = capped * (0.8 + random.random() * 0.4) # ±20%
225
+ return jittered / 1000.0
226
+
227
+
228
+ def _retry_after_from_response(response: httpx.Response) -> Optional[int]:
229
+ val = response.headers.get("retry-after")
230
+ if val is not None:
231
+ try:
232
+ return int(math.ceil(float(val)))
233
+ except (ValueError, TypeError):
234
+ pass
235
+ return None
236
+
237
+
238
+ def _raise_for_status(response: httpx.Response) -> None:
239
+ if response.status_code < 400:
240
+ return
241
+ raise MeshAPIError.from_response(response)
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Sync HTTP client
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ class SyncHttpClient:
250
+ def __init__(self, config: MeshAPIConfig) -> None:
251
+ self._config = config
252
+ self._client = config.httpx_client or httpx.Client(
253
+ base_url=config.base_url,
254
+ timeout=config.timeout,
255
+ )
256
+
257
+ def _headers(self) -> Dict[str, str]:
258
+ return {
259
+ "Authorization": f"Bearer {self._config.token}",
260
+ "Content-Type": "application/json",
261
+ "Accept": "application/json",
262
+ _SDK_VERSION_HEADER: _SDK_VERSION_VALUE,
263
+ }
264
+
265
+ def _request(
266
+ self,
267
+ method: str,
268
+ path: str,
269
+ *,
270
+ params: Optional[Dict[str, Any]] = None,
271
+ json_body: Optional[Any] = None,
272
+ stream: bool = False,
273
+ ) -> httpx.Response:
274
+ kwargs: Dict[str, Any] = {
275
+ "headers": self._headers(),
276
+ "params": params,
277
+ }
278
+ if json_body is not None:
279
+ kwargs["json"] = json_body
280
+
281
+ for attempt in range(self._config.max_retries + 1):
282
+ if stream:
283
+ # Streaming: no retry, open the stream and return immediately
284
+ req = self._client.request(method, path, **kwargs)
285
+ _raise_for_status(req)
286
+ return req
287
+
288
+ response = self._client.request(method, path, **kwargs)
289
+ if response.status_code in _RETRY_STATUS_CODES and attempt < self._config.max_retries:
290
+ delay = _compute_delay_s(attempt, _retry_after_from_response(response))
291
+ time.sleep(delay)
292
+ continue
293
+ _raise_for_status(response)
294
+ return response
295
+
296
+ # Should never reach here
297
+ raise RuntimeError("unreachable")
298
+
299
+ def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
300
+ response = self._request("GET", path, params=params)
301
+ if response.status_code == 204:
302
+ return None
303
+ return response.json()
304
+
305
+ def post(self, path: str, body: Any) -> Any:
306
+ response = self._request("POST", path, json_body=body)
307
+ if response.status_code == 204:
308
+ return None
309
+ return response.json()
310
+
311
+ def patch(self, path: str, body: Any) -> Any:
312
+ response = self._request("PATCH", path, json_body=body)
313
+ if response.status_code == 204:
314
+ return None
315
+ return response.json()
316
+
317
+ def delete(self, path: str) -> None:
318
+ response = self._request("DELETE", path)
319
+ if response.status_code == 204:
320
+ return
321
+ response.json() # consume body; _raise_for_status already ran
322
+
323
+ def get_bytes(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> bytes:
324
+ response = self._request("GET", path, params=params)
325
+ return response.content
326
+
327
+ def stream(self, path: str, body: Any) -> Iterator[ChatCompletionChunk]:
328
+ with self._client.stream("POST", path, json=body, headers=self._headers()) as response:
329
+ _raise_for_status(response)
330
+ yield from _iter_sse(response)
331
+
332
+ def stream_json(self, path: str, body: Any, model_cls: Type[T]) -> Iterator[T]:
333
+ with self._client.stream("POST", path, json=body, headers=self._headers()) as response:
334
+ _raise_for_status(response)
335
+ yield from _iter_json_sse(response, model_cls)
336
+
337
+ def close(self) -> None:
338
+ self._client.close()
339
+
340
+ def __enter__(self) -> "SyncHttpClient":
341
+ return self
342
+
343
+ def __exit__(self, *args: Any) -> None:
344
+ self.close()
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # Async HTTP client
349
+ # ---------------------------------------------------------------------------
350
+
351
+
352
+ class AsyncHttpClient:
353
+ def __init__(self, config: MeshAPIConfig) -> None:
354
+ self._config = config
355
+ self._client = config.async_httpx_client or httpx.AsyncClient(
356
+ base_url=config.base_url,
357
+ timeout=config.timeout,
358
+ )
359
+
360
+ def _headers(self) -> Dict[str, str]:
361
+ return {
362
+ "Authorization": f"Bearer {self._config.token}",
363
+ "Content-Type": "application/json",
364
+ "Accept": "application/json",
365
+ _SDK_VERSION_HEADER: _SDK_VERSION_VALUE,
366
+ }
367
+
368
+ async def _request(
369
+ self,
370
+ method: str,
371
+ path: str,
372
+ *,
373
+ params: Optional[Dict[str, Any]] = None,
374
+ json_body: Optional[Any] = None,
375
+ ) -> httpx.Response:
376
+ kwargs: Dict[str, Any] = {
377
+ "headers": self._headers(),
378
+ "params": params,
379
+ }
380
+ if json_body is not None:
381
+ kwargs["json"] = json_body
382
+
383
+ for attempt in range(self._config.max_retries + 1):
384
+ response = await self._client.request(method, path, **kwargs)
385
+ if response.status_code in _RETRY_STATUS_CODES and attempt < self._config.max_retries:
386
+ delay = _compute_delay_s(attempt, _retry_after_from_response(response))
387
+ await asyncio.sleep(delay)
388
+ continue
389
+ _raise_for_status(response)
390
+ return response
391
+
392
+ raise RuntimeError("unreachable")
393
+
394
+ async def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
395
+ response = await self._request("GET", path, params=params)
396
+ if response.status_code == 204:
397
+ return None
398
+ return response.json()
399
+
400
+ async def post(self, path: str, body: Any) -> Any:
401
+ response = await self._request("POST", path, json_body=body)
402
+ if response.status_code == 204:
403
+ return None
404
+ return response.json()
405
+
406
+ async def patch(self, path: str, body: Any) -> Any:
407
+ response = await self._request("PATCH", path, json_body=body)
408
+ if response.status_code == 204:
409
+ return None
410
+ return response.json()
411
+
412
+ async def delete(self, path: str) -> None:
413
+ response = await self._request("DELETE", path)
414
+ if response.status_code == 204:
415
+ return
416
+
417
+ async def get_bytes(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> bytes:
418
+ response = await self._request("GET", path, params=params)
419
+ return response.content
420
+
421
+ async def stream(self, path: str, body: Any) -> AsyncIterator[ChatCompletionChunk]:
422
+ async with self._client.stream(
423
+ "POST", path, json=body, headers=self._headers()
424
+ ) as response:
425
+ _raise_for_status(response)
426
+ async for chunk in _aiter_sse(response):
427
+ yield chunk
428
+
429
+ async def stream_json(self, path: str, body: Any, model_cls: Type[T]) -> AsyncIterator[T]:
430
+ async with self._client.stream(
431
+ "POST", path, json=body, headers=self._headers()
432
+ ) as response:
433
+ _raise_for_status(response)
434
+ async for item in _aiter_json_sse(response, model_cls):
435
+ yield item
436
+
437
+ async def aclose(self) -> None:
438
+ await self._client.aclose()
439
+
440
+ async def __aenter__(self) -> "AsyncHttpClient":
441
+ return self
442
+
443
+ async def __aexit__(self, *args: Any) -> None:
444
+ await self.aclose()