11# Copyright (c) ModelScope Contributors. All rights reserved.
22"""Minimal HTTP JSON client for Tavily REST API (stdlib only)."""
33import json
4- from typing import Any , Dict
4+ from typing import Any , Dict , Optional
55from urllib .error import HTTPError , URLError
66from urllib .request import Request , urlopen
77
88
9+ class TavilyHTTPError (RuntimeError ):
10+ """A Tavily call that failed, with the pieces a caller can act on.
11+
12+ Plain ``RuntimeError`` forced every caller to re-parse the message to tell
13+ "you are out of quota, ask the user for a key" from "that host is down".
14+ Keyless mode makes that distinction routine rather than exceptional — the
15+ free tier is a small hourly bucket — so the parts travel as fields:
16+ ``status`` (HTTP code, None for transport failures), ``code`` (Tavily's own
17+ machine-readable ``error.code``, e.g. ``hourly_cap_reached``) and
18+ ``retry_after`` seconds when the response carried one.
19+ """
20+
21+ def __init__ (self ,
22+ message : str ,
23+ * ,
24+ status : Optional [int ] = None ,
25+ code : str = '' ,
26+ retry_after : Optional [int ] = None ,
27+ detail : Any = None ):
28+ super ().__init__ (message )
29+ self .status = status
30+ self .code = code
31+ self .retry_after = retry_after
32+ self .detail = detail
33+
34+ @property
35+ def is_quota (self ) -> bool :
36+ """Out of quota — retryable later, and fixable now with an API key."""
37+ return self .status == 429 or self .code in ('hourly_cap_reached' ,
38+ 'rate_limit_exceeded' )
39+
40+ @property
41+ def is_auth (self ) -> bool :
42+ return self .status in (401 , 403 )
43+
44+
45+ def _ssl_context ():
46+ """A verifying TLS context that works on interpreters with no CA store.
47+
48+ ``urlopen`` uses the interpreter's default store, which is empty in some
49+ virtualenvs (``ssl.get_default_verify_paths().cafile is None`` — measured on
50+ the WebUI backend's venv, where every Tavily call died with
51+ CERTIFICATE_VERIFY_FAILED). certifi is already an indirect dependency there;
52+ when it is missing we hand back None so urlopen behaves exactly as before.
53+ Never disables verification.
54+ """
55+ try :
56+ import certifi
57+ import ssl
58+ return ssl .create_default_context (cafile = certifi .where ())
59+ except Exception :
60+ return None
61+
62+
63+ def _parse_error_body (raw : str ) -> Any :
64+ try :
65+ return json .loads (raw ) if raw else {}
66+ except json .JSONDecodeError :
67+ return {'raw' : raw }
68+
69+
70+ def _dig_error (detail : Any ) -> tuple :
71+ """``(code, message, retry_after)`` out of Tavily's error envelope.
72+
73+ Two shapes are in the wild: ``{"error": {"code", "message",
74+ "retry_after_seconds"}}`` (keyless quota) and ``{"detail": {"error": ...}}``
75+ (auth). Anything else degrades to empty strings rather than raising while
76+ already handling an error.
77+ """
78+ code = message = ''
79+ retry_after = None
80+ node = detail
81+ if isinstance (node , dict ) and isinstance (node .get ('detail' ), dict ):
82+ node = node ['detail' ]
83+ if isinstance (node , dict ):
84+ err = node .get ('error' )
85+ if isinstance (err , dict ):
86+ code = str (err .get ('code' ) or '' )
87+ message = str (err .get ('message' ) or '' )
88+ ra = err .get ('retry_after_seconds' )
89+ if isinstance (ra , (int , float )):
90+ retry_after = int (ra )
91+ elif isinstance (err , str ):
92+ message = err
93+ return code , message , retry_after
94+
95+
996def post_json (
1097 url : str ,
1198 body : Dict [str , Any ],
1299 * ,
13100 timeout : float = 120.0 ,
101+ headers : Optional [Dict [str , str ]] = None ,
14102) -> Dict [str , Any ]:
15103 """
16104 POST JSON and parse JSON response.
17105
106+ ``headers`` is merged over the defaults — that is how keyless mode is
107+ selected (``X-Tavily-Access-Mode: keyless``).
108+
18109 Raises:
19- RuntimeError: on HTTP errors or invalid JSON (includes Tavily error body).
110+ TavilyHTTPError: on HTTP errors or invalid JSON (carries Tavily's own
111+ error code / retry-after so callers can tell quota from outage).
20112 """
21113 data = json .dumps (body , ensure_ascii = False ).encode ('utf-8' )
22- req = Request (
23- url ,
24- data = data ,
25- method = 'POST' ,
26- headers = {
27- 'Content-Type' : 'application/json' ,
28- 'Accept' : 'application/json' ,
29- },
30- )
114+ merged = {
115+ 'Content-Type' : 'application/json' ,
116+ 'Accept' : 'application/json' ,
117+ }
118+ merged .update (headers or {})
119+ req = Request (url , data = data , method = 'POST' , headers = merged )
31120 try :
32- with urlopen (req , timeout = timeout ) as resp :
121+ with urlopen (req , timeout = timeout , context = _ssl_context () ) as resp :
33122 raw = resp .read ().decode ('utf-8' , errors = 'replace' )
34123 if not raw .strip ():
35124 return {}
@@ -40,10 +129,24 @@ def post_json(
40129 err_body = e .read ().decode ('utf-8' , errors = 'replace' )
41130 except Exception :
42131 pass
43- try :
44- detail = json .loads (err_body ) if err_body else {}
45- except json .JSONDecodeError :
46- detail = {'raw' : err_body }
47- raise RuntimeError (f'Tavily HTTP { e .code } : { detail } ' ) from e
132+ detail = _parse_error_body (err_body )
133+ code , message , retry_after = _dig_error (detail )
134+ if retry_after is None :
135+ header_value = None
136+ try :
137+ header_value = e .headers .get ('retry-after' )
138+ except Exception :
139+ pass
140+ if header_value :
141+ try :
142+ retry_after = int (float (header_value ))
143+ except (TypeError , ValueError ):
144+ retry_after = None
145+ raise TavilyHTTPError (
146+ f'Tavily HTTP { e .code } : { message or detail } ' ,
147+ status = e .code ,
148+ code = code ,
149+ retry_after = retry_after ,
150+ detail = detail ) from e
48151 except URLError as e :
49- raise RuntimeError (f'Tavily network error: { e } ' ) from e
152+ raise TavilyHTTPError (f'Tavily network error: { e } ' ) from e
0 commit comments