Skip to content

Make the HTTP client an implementation detail, then decide on requests vs httpx2 #31

Description

@tomchop

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 AsyncYetiApi alongside 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions