novarch 0.1.2__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.
- novarch/__init__.py +18 -0
- novarch/_client.py +393 -0
- novarch/_gate.py +591 -0
- novarch/_state.py +188 -0
- novarch/_version.py +22 -0
- novarch/augur.py +489 -0
- novarch/exceptions.py +91 -0
- novarch/integrations/__init__.py +16 -0
- novarch/integrations/claude_agent_sdk.py +95 -0
- novarch/integrations/langchain.py +279 -0
- novarch/integrations/langgraph.py +425 -0
- novarch/integrations/llm_protocol.py +55 -0
- novarch/schema.py +307 -0
- novarch/signals.py +58 -0
- novarch/verticals/__init__.py +16 -0
- novarch/verticals/ap.py +13 -0
- novarch/verticals/cs.py +16 -0
- novarch/verticals/generic.py +12 -0
- novarch-0.1.2.dist-info/METADATA +126 -0
- novarch-0.1.2.dist-info/RECORD +23 -0
- novarch-0.1.2.dist-info/WHEEL +4 -0
- novarch-0.1.2.dist-info/licenses/LICENSE +176 -0
- novarch-0.1.2.dist-info/licenses/NOTICE +22 -0
novarch/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Novarch SDK — public surface customers depend on.
|
|
2
|
+
|
|
3
|
+
``from novarch import augur, NovarchKillError`` is the only import customers
|
|
4
|
+
need. Everything else (HTTP client, ContextVar plumbing, upstream wrapping)
|
|
5
|
+
is private.
|
|
6
|
+
|
|
7
|
+
See ``docs/designs/live-system-implementation.md`` Phase 3.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from novarch._version import __version__ as __version__ # re-export (not in __all__)
|
|
12
|
+
from novarch.augur import augur
|
|
13
|
+
from novarch.exceptions import NovarchKillError
|
|
14
|
+
|
|
15
|
+
# ``__version__`` is accessible as ``novarch.__version__`` but deliberately
|
|
16
|
+
# kept out of ``__all__`` — the ``*``-exported surface stays augur +
|
|
17
|
+
# NovarchKillError only (see test_public_novarch_surface_is_minimal).
|
|
18
|
+
__all__ = ["augur", "NovarchKillError"]
|
novarch/_client.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""HTTP client to ``novarch_server``.
|
|
2
|
+
|
|
3
|
+
Sync ``httpx.Client`` because v0 SDK is sync. One client is created per
|
|
4
|
+
session at decoration time and torn down at function exit.
|
|
5
|
+
|
|
6
|
+
The client deals only in plain dicts — Pydantic validation lives at the
|
|
7
|
+
caller (decorator) layer where it can attach session/action context to
|
|
8
|
+
errors.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from novarch._version import VERSION as _SDK_VERSION
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NovarchAPIError(RuntimeError):
|
|
21
|
+
"""Raised when the backend returns a non-2xx response.
|
|
22
|
+
|
|
23
|
+
The decorator catches this to translate timeouts and 5xxs into a
|
|
24
|
+
fail-closed ``NovarchKillError`` rather than letting an httpx
|
|
25
|
+
``HTTPStatusError`` bubble up to customer code.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
29
|
+
super().__init__(f"novarch_server {status_code}: {detail}")
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
self.detail = detail
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NovarchClient:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
server_url: str,
|
|
38
|
+
*,
|
|
39
|
+
timeout_s: float = 30.0,
|
|
40
|
+
http_client: httpx.Client | None = None,
|
|
41
|
+
sdk_key: str | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
# Strip trailing slash so urljoin-style paths in the methods stay
|
|
44
|
+
# readable (`f"{base}/sessions"` instead of conditional joins).
|
|
45
|
+
self._base = server_url.rstrip("/")
|
|
46
|
+
# ``http_client`` is the SDK's seam for tests: pass a TestClient (or
|
|
47
|
+
# any httpx.Client subclass) bound to a TestClient-driven app and
|
|
48
|
+
# the SDK talks to your in-process server with no socket.
|
|
49
|
+
self._owns_http = http_client is None
|
|
50
|
+
# Phase 2 task 1: resolve the SDK key from explicit kwarg → env →
|
|
51
|
+
# nothing. When the SDK is calling a configured server the
|
|
52
|
+
# ``X-Novarch-Key`` header is required; partner agents deploy with
|
|
53
|
+
# ``NOVARCH_SDK_KEY`` set in their env. Tests that pass an
|
|
54
|
+
# explicit ``http_client`` bypass this default-header path —
|
|
55
|
+
# FastAPI's dependency_overrides handle auth in that case.
|
|
56
|
+
resolved_key = (
|
|
57
|
+
sdk_key if sdk_key is not None else os.environ.get("NOVARCH_SDK_KEY")
|
|
58
|
+
)
|
|
59
|
+
# Phase 6 task 4: declare the SDK version on every request so the
|
|
60
|
+
# server can reject a major.minor-incompatible SDK with one explicit
|
|
61
|
+
# 400 instead of a silent fail-closed kill on every action. Sent only
|
|
62
|
+
# on clients that own their httpx.Client; tests injecting an
|
|
63
|
+
# ``http_client`` (TestClient) deliberately skip the default headers.
|
|
64
|
+
default_headers: dict[str, str] = {"X-Novarch-SDK-Version": _SDK_VERSION}
|
|
65
|
+
if resolved_key:
|
|
66
|
+
default_headers["X-Novarch-Key"] = resolved_key
|
|
67
|
+
self._http = http_client or httpx.Client(
|
|
68
|
+
base_url=self._base,
|
|
69
|
+
timeout=timeout_s,
|
|
70
|
+
headers=default_headers,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
if self._owns_http:
|
|
75
|
+
self._http.close()
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> NovarchClient:
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *exc: Any) -> None:
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
def create_session(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
tenant_id: str,
|
|
87
|
+
team_id: str,
|
|
88
|
+
agent_id: str,
|
|
89
|
+
session_id: str | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
body: dict[str, Any] = {
|
|
92
|
+
"tenant_id": tenant_id,
|
|
93
|
+
"team_id": team_id,
|
|
94
|
+
"agent_id": agent_id,
|
|
95
|
+
}
|
|
96
|
+
if session_id is not None:
|
|
97
|
+
body["session_id"] = session_id
|
|
98
|
+
return self._post_json("/sessions", body)
|
|
99
|
+
|
|
100
|
+
def submit_action(
|
|
101
|
+
self,
|
|
102
|
+
session_id: str,
|
|
103
|
+
*,
|
|
104
|
+
action_type: str,
|
|
105
|
+
action_context: dict[str, Any],
|
|
106
|
+
agent_reasoning: str,
|
|
107
|
+
tools_called: list[dict[str, Any]],
|
|
108
|
+
action_id: str | None = None,
|
|
109
|
+
poll_timeout_s: float | None = None,
|
|
110
|
+
) -> dict[str, Any]:
|
|
111
|
+
body: dict[str, Any] = {
|
|
112
|
+
"action_type": action_type,
|
|
113
|
+
"action_context": action_context,
|
|
114
|
+
"agent_reasoning": agent_reasoning,
|
|
115
|
+
"tools_called": tools_called,
|
|
116
|
+
}
|
|
117
|
+
# Zombie-hold resolution Task 6: forward the SDK's own poll deadline
|
|
118
|
+
# so the server can stamp decisions.hold_deadline precisely (Task 5)
|
|
119
|
+
# instead of leaving it NULL for the conservative reaper sweep.
|
|
120
|
+
# Omitted (not sent as None) for back-compat with servers that
|
|
121
|
+
# predate this field.
|
|
122
|
+
if poll_timeout_s is not None:
|
|
123
|
+
body["poll_timeout_s"] = poll_timeout_s
|
|
124
|
+
if action_id is not None:
|
|
125
|
+
body["action_id"] = action_id
|
|
126
|
+
return self._post_json(f"/sessions/{session_id}/actions", body)
|
|
127
|
+
|
|
128
|
+
def report_timeout(self, session_id: str, action_id: str) -> None:
|
|
129
|
+
"""Best-effort: tell the server the agent gave up waiting on a HOLD
|
|
130
|
+
so it fail-closes server-side (precise) instead of waiting for the
|
|
131
|
+
reaper backstop (Task 4). Callers swallow ``NovarchAPIError`` —
|
|
132
|
+
the local fail-closed raise stands regardless of whether this
|
|
133
|
+
report lands."""
|
|
134
|
+
self._post_no_content(f"/sessions/{session_id}/actions/{action_id}/timeout")
|
|
135
|
+
|
|
136
|
+
def complete_session(self, session_id: str) -> None:
|
|
137
|
+
"""Mark the session completed (Task 2.5 / D3).
|
|
138
|
+
|
|
139
|
+
Called by ``@augur.session``'s ``finally`` on every exit path
|
|
140
|
+
(normal return or exception, including ``NovarchKillError``) so the
|
|
141
|
+
backend leaves ``status='active'``. Best-effort: the decorator
|
|
142
|
+
wraps this in a try/except and never lets a completion failure
|
|
143
|
+
break customer code, so any ``NovarchAPIError`` here is the
|
|
144
|
+
caller's problem to catch, not ours to hide."""
|
|
145
|
+
self._post_no_content(f"/sessions/{session_id}/complete")
|
|
146
|
+
|
|
147
|
+
def get_decision(self, session_id: str, action_id: str) -> dict[str, Any]:
|
|
148
|
+
"""Returns ``{"decision": {...}, "verdict": {...} | None}`` immediately."""
|
|
149
|
+
return self._get_json(
|
|
150
|
+
f"/sessions/{session_id}/actions/{action_id}/decision"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def wait_for_verdict(
|
|
154
|
+
self, session_id: str, action_id: str, *, wait_s: float
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""Long-poll for the operator verdict (Phase 4 Task 4).
|
|
157
|
+
|
|
158
|
+
The server holds the request open up to ``wait_s`` seconds until a
|
|
159
|
+
verdict for this action is committed, then returns the same
|
|
160
|
+
``{"decision": ..., "verdict": ...}`` shape as ``get_decision`` (verdict
|
|
161
|
+
``None`` on timeout, so the caller loops). The caller keeps ``wait_s``
|
|
162
|
+
below the client's read timeout (default 30s) so the hold returns before
|
|
163
|
+
httpx gives up; a verdict that lands mid-hold is returned at once
|
|
164
|
+
regardless, so a low client timeout only re-polls more often.
|
|
165
|
+
"""
|
|
166
|
+
return self._get_json(
|
|
167
|
+
f"/sessions/{session_id}/actions/{action_id}/decision/wait?wait={wait_s}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def submit_operator_verdict(
|
|
171
|
+
self,
|
|
172
|
+
session_id: str,
|
|
173
|
+
action_id: str,
|
|
174
|
+
*,
|
|
175
|
+
verdict: str,
|
|
176
|
+
note: str | None = None,
|
|
177
|
+
operator_id: str | None = None,
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
"""Phase 2 task 3: ``operator_id`` (when supplied) is sent as the
|
|
180
|
+
``X-Operator-Id`` header. The server validates it against the
|
|
181
|
+
``NOVARCH_OPERATOR_IDS`` allowlist and writes it into the audit
|
|
182
|
+
row's ``operator_id`` column."""
|
|
183
|
+
body: dict[str, Any] = {"verdict": verdict}
|
|
184
|
+
if note is not None:
|
|
185
|
+
body["note"] = note
|
|
186
|
+
headers: dict[str, str] = {}
|
|
187
|
+
if operator_id is not None:
|
|
188
|
+
headers["X-Operator-Id"] = operator_id
|
|
189
|
+
return self._post_json(
|
|
190
|
+
f"/sessions/{session_id}/actions/{action_id}/decision",
|
|
191
|
+
body,
|
|
192
|
+
headers=headers,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# Internals
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def _post_json(
|
|
200
|
+
self,
|
|
201
|
+
path: str,
|
|
202
|
+
body: dict[str, Any],
|
|
203
|
+
headers: dict[str, str] | None = None,
|
|
204
|
+
) -> dict[str, Any]:
|
|
205
|
+
try:
|
|
206
|
+
r = self._http.post(path, json=body, headers=headers or None)
|
|
207
|
+
except httpx.HTTPError as e:
|
|
208
|
+
raise NovarchAPIError(0, f"network error: {e}") from e
|
|
209
|
+
if r.status_code >= 400:
|
|
210
|
+
raise NovarchAPIError(r.status_code, _safe_body(r))
|
|
211
|
+
return _json_or_raise(r)
|
|
212
|
+
|
|
213
|
+
def _get_json(self, path: str) -> dict[str, Any]:
|
|
214
|
+
try:
|
|
215
|
+
r = self._http.get(path)
|
|
216
|
+
except httpx.HTTPError as e:
|
|
217
|
+
raise NovarchAPIError(0, f"network error: {e}") from e
|
|
218
|
+
if r.status_code >= 400:
|
|
219
|
+
raise NovarchAPIError(r.status_code, _safe_body(r))
|
|
220
|
+
return _json_or_raise(r)
|
|
221
|
+
|
|
222
|
+
def _post_no_content(self, path: str) -> None:
|
|
223
|
+
"""POST with no request body. The response body (if any) is never
|
|
224
|
+
parsed — distinct from ``_post_json``, which always parses JSON.
|
|
225
|
+
Used by endpoints like ``/complete`` (204) and the Task 6
|
|
226
|
+
``/timeout`` self-report (200 JSON) that are state-transition
|
|
227
|
+
writes whose response payload the caller doesn't need."""
|
|
228
|
+
try:
|
|
229
|
+
r = self._http.post(path)
|
|
230
|
+
except httpx.HTTPError as e:
|
|
231
|
+
raise NovarchAPIError(0, f"network error: {e}") from e
|
|
232
|
+
if r.status_code >= 400:
|
|
233
|
+
raise NovarchAPIError(r.status_code, _safe_body(r))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class NovarchAsyncClient:
|
|
237
|
+
"""Async mirror of ``NovarchClient`` over ``httpx.AsyncClient``.
|
|
238
|
+
|
|
239
|
+
Intended for framework adapters that run on an event loop (LangGraph,
|
|
240
|
+
CrewAI, ADK, etc.) where the sync client would block the loop during
|
|
241
|
+
the judge round-trip. Constructor surface is identical to
|
|
242
|
+
``NovarchClient`` so adapters can switch by swapping the class.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(
|
|
246
|
+
self,
|
|
247
|
+
server_url: str,
|
|
248
|
+
*,
|
|
249
|
+
timeout_s: float = 30.0,
|
|
250
|
+
http_client: httpx.AsyncClient | None = None,
|
|
251
|
+
sdk_key: str | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
self._base = server_url.rstrip("/")
|
|
254
|
+
self._owns_http = http_client is None
|
|
255
|
+
resolved_key = (
|
|
256
|
+
sdk_key if sdk_key is not None else os.environ.get("NOVARCH_SDK_KEY")
|
|
257
|
+
)
|
|
258
|
+
default_headers: dict[str, str] = {"X-Novarch-SDK-Version": _SDK_VERSION}
|
|
259
|
+
if resolved_key:
|
|
260
|
+
default_headers["X-Novarch-Key"] = resolved_key
|
|
261
|
+
self._http = http_client or httpx.AsyncClient(
|
|
262
|
+
base_url=self._base,
|
|
263
|
+
timeout=timeout_s,
|
|
264
|
+
headers=default_headers,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
async def aclose(self) -> None:
|
|
268
|
+
if self._owns_http:
|
|
269
|
+
await self._http.aclose()
|
|
270
|
+
|
|
271
|
+
async def __aenter__(self) -> NovarchAsyncClient:
|
|
272
|
+
return self
|
|
273
|
+
|
|
274
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
275
|
+
await self.aclose()
|
|
276
|
+
|
|
277
|
+
async def acreate_session(
|
|
278
|
+
self,
|
|
279
|
+
*,
|
|
280
|
+
tenant_id: str,
|
|
281
|
+
team_id: str,
|
|
282
|
+
agent_id: str,
|
|
283
|
+
session_id: str | None = None,
|
|
284
|
+
) -> dict[str, Any]:
|
|
285
|
+
body: dict[str, Any] = {
|
|
286
|
+
"tenant_id": tenant_id,
|
|
287
|
+
"team_id": team_id,
|
|
288
|
+
"agent_id": agent_id,
|
|
289
|
+
}
|
|
290
|
+
if session_id is not None:
|
|
291
|
+
body["session_id"] = session_id
|
|
292
|
+
return await self._apost_json("/sessions", body)
|
|
293
|
+
|
|
294
|
+
async def asubmit_action(
|
|
295
|
+
self,
|
|
296
|
+
session_id: str,
|
|
297
|
+
*,
|
|
298
|
+
action_type: str,
|
|
299
|
+
action_context: dict[str, Any],
|
|
300
|
+
agent_reasoning: str,
|
|
301
|
+
tools_called: list[dict[str, Any]],
|
|
302
|
+
action_id: str | None = None,
|
|
303
|
+
poll_timeout_s: float | None = None,
|
|
304
|
+
) -> dict[str, Any]:
|
|
305
|
+
body: dict[str, Any] = {
|
|
306
|
+
"action_type": action_type,
|
|
307
|
+
"action_context": action_context,
|
|
308
|
+
"agent_reasoning": agent_reasoning,
|
|
309
|
+
"tools_called": tools_called,
|
|
310
|
+
}
|
|
311
|
+
if poll_timeout_s is not None:
|
|
312
|
+
body["poll_timeout_s"] = poll_timeout_s
|
|
313
|
+
if action_id is not None:
|
|
314
|
+
body["action_id"] = action_id
|
|
315
|
+
return await self._apost_json(f"/sessions/{session_id}/actions", body)
|
|
316
|
+
|
|
317
|
+
async def await_for_verdict(
|
|
318
|
+
self, session_id: str, action_id: str, *, wait_s: float
|
|
319
|
+
) -> dict[str, Any]:
|
|
320
|
+
return await self._aget_json(
|
|
321
|
+
f"/sessions/{session_id}/actions/{action_id}/decision/wait?wait={wait_s}"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
async def areport_timeout(self, session_id: str, action_id: str) -> None:
|
|
325
|
+
"""Async mirror of ``NovarchClient.report_timeout`` (Task 6)."""
|
|
326
|
+
await self._apost_no_content(
|
|
327
|
+
f"/sessions/{session_id}/actions/{action_id}/timeout"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
# Internals — reuse module-level _json_or_raise and _safe_body helpers
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
async def _apost_json(
|
|
335
|
+
self,
|
|
336
|
+
path: str,
|
|
337
|
+
body: dict[str, Any],
|
|
338
|
+
headers: dict[str, str] | None = None,
|
|
339
|
+
) -> dict[str, Any]:
|
|
340
|
+
try:
|
|
341
|
+
r = await self._http.post(path, json=body, headers=headers or None)
|
|
342
|
+
except httpx.HTTPError as e:
|
|
343
|
+
raise NovarchAPIError(0, f"network error: {e}") from e
|
|
344
|
+
if r.status_code >= 400:
|
|
345
|
+
raise NovarchAPIError(r.status_code, _safe_body(r))
|
|
346
|
+
return _json_or_raise(r)
|
|
347
|
+
|
|
348
|
+
async def _aget_json(self, path: str) -> dict[str, Any]:
|
|
349
|
+
try:
|
|
350
|
+
r = await self._http.get(path)
|
|
351
|
+
except httpx.HTTPError as e:
|
|
352
|
+
raise NovarchAPIError(0, f"network error: {e}") from e
|
|
353
|
+
if r.status_code >= 400:
|
|
354
|
+
raise NovarchAPIError(r.status_code, _safe_body(r))
|
|
355
|
+
return _json_or_raise(r)
|
|
356
|
+
|
|
357
|
+
async def _apost_no_content(self, path: str) -> None:
|
|
358
|
+
"""Async mirror of ``NovarchClient._post_no_content``: POST with no
|
|
359
|
+
request body, response body (204 or 200 JSON) never parsed."""
|
|
360
|
+
try:
|
|
361
|
+
r = await self._http.post(path)
|
|
362
|
+
except httpx.HTTPError as e:
|
|
363
|
+
raise NovarchAPIError(0, f"network error: {e}") from e
|
|
364
|
+
if r.status_code >= 400:
|
|
365
|
+
raise NovarchAPIError(r.status_code, _safe_body(r))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _json_or_raise(r: httpx.Response) -> dict[str, Any]:
|
|
369
|
+
"""Parse a 2xx body as JSON, or raise ``NovarchAPIError``.
|
|
370
|
+
|
|
371
|
+
A 200 with a non-JSON body (a proxy, captive portal, or wrong service
|
|
372
|
+
answering) would otherwise raise a raw ``json.JSONDecodeError`` from
|
|
373
|
+
``r.json()`` — which is not an ``httpx.HTTPError``, so it would bypass the
|
|
374
|
+
decorator's fail-closed translation and crash customer code with an
|
|
375
|
+
undocumented exception. Funnel it through ``NovarchAPIError`` so the
|
|
376
|
+
write decorator turns it into a ``NovarchKillError`` like every other
|
|
377
|
+
transport failure.
|
|
378
|
+
"""
|
|
379
|
+
try:
|
|
380
|
+
return r.json()
|
|
381
|
+
except ValueError as e: # json.JSONDecodeError is a ValueError subclass
|
|
382
|
+
content_type = r.headers.get("content-type", "unknown")
|
|
383
|
+
raise NovarchAPIError(
|
|
384
|
+
r.status_code,
|
|
385
|
+
f"expected JSON but got {content_type}: {_safe_body(r)[:200]}",
|
|
386
|
+
) from e
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _safe_body(r: httpx.Response) -> str:
|
|
390
|
+
try:
|
|
391
|
+
return r.json().get("detail") or r.text
|
|
392
|
+
except Exception:
|
|
393
|
+
return r.text
|