You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Make the HTTP client an implementation detail, then decide on requests vs httpx2 #31
requests currently leaks through the public surface of this package in several places, so changing the HTTP library would break callers. The goal here is to mask that first — make the library an implementation detail behind our own types — so the choice between requests and httpx becomes a contained, reversible decision rather than a breaking one.
User count is small, so a clean break in a 3.0 is acceptable.
Where requests leaks today
Transport errors escape untranslated.do_request() catches only requests.exceptions.HTTPError. Connection refused, DNS failure, TLS errors, timeouts, too many redirects — all reach the caller as requests.exceptions.*. Anyone handling them is coupled to requests.
Malformed responses escape as json.JSONDecodeError. Every method does json.loads(response), so a non-JSON 200 (a proxy's HTML error page, say) surfaces as a parsing error rather than an API error.
YetiApi.client is a public requests.Session. It's assigned in __init__ and replaced in auth_api_key(), and people reach into it — it was the only way to issue a DELETE until Dispatch DELETE in do_request #28.
requests_toolbelt is a dependency for one call.upload_dfiq_archive() builds a MultipartEncoder and immediately calls .to_string(), which reads the whole body into memory — so the streaming that requests_toolbelt exists for isn't used. Native multipart (files=) is equivalent, in either library.
The tests are coupled to requests.tests/api.py patches yeti.api.requests.Session.* 38 times (27 post, 5 get, 4 patch, 1 delete, 1 send). Any library change rewrites the suite.
What's already library-agnostic, and should stay that way: do_request()'s signature (method, URL, json_data, body, headers, params; returns bytes), the tls_cert constructor argument (a path), and the error types downstream actually uses. yeti-agents catches only YetiApiError and YetiAuthError, and never touches .client.
Phase 1 — mask it (3.0)
A transport error type. Add YetiConnectionError(YetiError) for anything that prevents a response — connection, DNS, TLS, redirects — and YetiTimeoutError(YetiConnectionError). Raise them from the underlying exception, so the cause stays visible in tracebacks while callers only ever catch yeti types.
Malformed responses become API errors. A body that doesn't parse as the expected JSON raises YetiApiError (or a YetiResponseError subclass) carrying the status code, not JSONDecodeError.
Make the session private.client → _session. do_request() is the supported low-level escape hatch, which is what client was being used for.
Default timeouts. There is currently no timeout anywhere, and requests has no default, so a server that accepts a connection and stalls hangs every call forever — including the agent's tool calls in yeti-agents. Add a constructor-level default (connect and read), overridable per call.
Drop requests_toolbelt. Use native multipart in upload_dfiq_archive().
Test against a transport seam, not a library. Put the actual HTTP call behind one small internal function or class, and give the tests a fake for it. Pass raw request bodies to requests as data #29 is the cautionary tale: patching Session.post, which accepts any keyword, let a body= that requests would reject pass unnoticed. The fake must reject arguments the real library would, or the tests should mock at the library's lowest seam (requests' Session.send, httpx's MockTransport).
At the end of phase 1, no requests type appears in any public signature, attribute, docstring or raised exception, and switching libraries touches one module plus the test fake.
Phase 2 — choose the client
Only worth doing once phase 1 is in, and decidable on its own merits.
If it's httpx, target httpx2, not httpx. Plain httpx (encode/httpx) has stayed at 0.28.1, still pre-1.0; the actively released line is httpx2 (github.com/pydantic/httpx2), at 2.13.1. The ecosystem is already moving: authlib now imports httpx2 and treats httpx as a deprecated fallback that it plans to remove.
What it would buy:
A native async client. yeti-agents is async and currently runs this client in asyncio.to_thread. The right shape is an AsyncYetiApialongside the sync one, sharing everything but the transport.
HTTP/2, and timeouts by default — though phase 1 adds timeouts regardless.
Behaviour to preserve or document:
CA bundle environment variables differ.requests honours REQUESTS_CA_BUNDLE and CURL_CA_BUNDLE; httpx uses SSL_CERT_FILE and SSL_CERT_DIR. Users behind TLS-intercepting proxies who rely on the former would fail silently after a swap. Either honour both, or call it out loudly in the release notes. (tls_cert is unaffected, since it's explicit.)
Proxies and .netrc from the environment. Both libraries read them by default (trust_env), but check the edge cases rather than assume.
TLS configuration style. httpx is moving towards ssl.SSLContext objects rather than path strings for verify, so tls_cert would become a context built internally.
Effort is mostly tests. With phase 1's seam in place, the transport module is a few dozen lines; the bulk is porting the fake and the e2e assumptions.
Versioning
Phase 1 is the breaking release (3.0): client goes away, and callers catching requests exceptions must catch YetiConnectionError instead. Phase 2 needs no further public-API change, so it can be a minor release — except for the CA-bundle environment variables, which must be in the notes either way.
requestscurrently leaks through the public surface of this package in several places, so changing the HTTP library would break callers. The goal here is to mask that first — make the library an implementation detail behind our own types — so the choice betweenrequestsand httpx becomes a contained, reversible decision rather than a breaking one.User count is small, so a clean break in a 3.0 is acceptable.
Where
requestsleaks todaydo_request()catches onlyrequests.exceptions.HTTPError. Connection refused, DNS failure, TLS errors, timeouts, too many redirects — all reach the caller asrequests.exceptions.*. Anyone handling them is coupled torequests.json.JSONDecodeError. Every method doesjson.loads(response), so a non-JSON 200 (a proxy's HTML error page, say) surfaces as a parsing error rather than an API error.YetiApi.clientis a publicrequests.Session. It's assigned in__init__and replaced inauth_api_key(), and people reach into it — it was the only way to issue a DELETE until Dispatch DELETE in do_request #28.requests_toolbeltis a dependency for one call.upload_dfiq_archive()builds aMultipartEncoderand immediately calls.to_string(), which reads the whole body into memory — so the streaming thatrequests_toolbeltexists for isn't used. Native multipart (files=) is equivalent, in either library.requests.tests/api.pypatchesyeti.api.requests.Session.*38 times (27post, 5get, 4patch, 1delete, 1send). Any library change rewrites the suite.What's already library-agnostic, and should stay that way:
do_request()'s signature (method, URL,json_data,body, headers, params; returnsbytes), thetls_certconstructor argument (a path), and the error types downstream actually uses. yeti-agents catches onlyYetiApiErrorandYetiAuthError, and never touches.client.Phase 1 — mask it (3.0)
YetiConnectionError(YetiError)for anything that prevents a response — connection, DNS, TLS, redirects — andYetiTimeoutError(YetiConnectionError). Raise themfromthe underlying exception, so the cause stays visible in tracebacks while callers only ever catch yeti types.YetiApiError(or aYetiResponseErrorsubclass) carrying the status code, notJSONDecodeError.client→_session.do_request()is the supported low-level escape hatch, which is whatclientwas being used for.requestshas no default, so a server that accepts a connection and stalls hangs every call forever — including the agent's tool calls in yeti-agents. Add a constructor-level default (connect and read), overridable per call.requests_toolbelt. Use native multipart inupload_dfiq_archive().Session.post, which accepts any keyword, let abody=thatrequestswould reject pass unnoticed. The fake must reject arguments the real library would, or the tests should mock at the library's lowest seam (requests'Session.send, httpx'sMockTransport).At the end of phase 1, no
requeststype appears in any public signature, attribute, docstring or raised exception, and switching libraries touches one module plus the test fake.Phase 2 — choose the client
Only worth doing once phase 1 is in, and decidable on its own merits.
If it's httpx, target
httpx2, nothttpx. Plainhttpx(encode/httpx) has stayed at 0.28.1, still pre-1.0; the actively released line ishttpx2(github.com/pydantic/httpx2), at 2.13.1. The ecosystem is already moving: authlib now importshttpx2and treatshttpxas a deprecated fallback that it plans to remove.What it would buy:
asyncio.to_thread. The right shape is anAsyncYetiApialongside the sync one, sharing everything but the transport.Behaviour to preserve or document:
requestshonoursREQUESTS_CA_BUNDLEandCURL_CA_BUNDLE; httpx usesSSL_CERT_FILEandSSL_CERT_DIR. Users behind TLS-intercepting proxies who rely on the former would fail silently after a swap. Either honour both, or call it out loudly in the release notes. (tls_certis unaffected, since it's explicit.).netrcfrom the environment. Both libraries read them by default (trust_env), but check the edge cases rather than assume.ssl.SSLContextobjects rather than path strings forverify, sotls_certwould become a context built internally.Effort is mostly tests. With phase 1's seam in place, the transport module is a few dozen lines; the bulk is porting the fake and the e2e assumptions.
Versioning
Phase 1 is the breaking release (3.0):
clientgoes away, and callers catchingrequestsexceptions must catchYetiConnectionErrorinstead. Phase 2 needs no further public-API change, so it can be a minor release — except for the CA-bundle environment variables, which must be in the notes either way.