Skip to content

feat(client): implement ShadeClient for per-instance configuration (#2) - #55

Merged
codebestia merged 3 commits into
ShadeProtocol:mainfrom
daveades:feat/2-shade-client
Jul 30, 2026
Merged

feat(client): implement ShadeClient for per-instance configuration (#2)#55
codebestia merged 3 commits into
ShadeProtocol:mainfrom
daveades:feat/2-shade-client

Conversation

@daveades

@daveades daveades commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Description

Implements ShadeClient as a real per-instance configuration object, so a multi-tenant application (a SaaS acting for many merchants) can hold one client per tenant instead of mutating the global shade module config.

acme = ShadeClient(api_key="sk_live_acme")
globex = ShadeClient(api_key="sk_live_globex")

Payments(client=acme).retrieve("pay_1")    # acme's credentials
Payments().retrieve("pay_1")               # falls back to shade.api_key

ShadeClient (src/shade/client.py) takes api_key, environment, api_base, timeout and max_retries, each falling back to the matching global setting when omitted. The fallback resolves once at construction, so assigning shade.timeout later never mutates a client that already exists.

shade.api_key is added as a global setting to back that fallback, alongside the existing shade.api_base / shade.timeout / shade.max_retries.

ShadeClient.from_env() builds a client from SHADE_API_KEY and SHADE_ENVIRONMENT. Either variable may be absent, in which case the normal global fallback applies. Keyword arguments override the environment, so ShadeClient.from_env(timeout=5.0) takes the key from the environment and sets the rest explicitly.

Missing credentials now raise AuthenticationError rather than ValueError, and the message names all three ways to supply a key:

No API key provided. Pass api_key= to ShadeClient, set shade.api_key, or set
the SHADE_API_KEY environment variable.

BaseResource (src/shade/resources/base.py) gives every resource the optional client= kwarg, resolving to a shared client built from the global config when omitted. That shared client is cached but rebuilt whenever a global setting changes, and it is resolved on each access rather than captured at construction — so a resource built before shade.api_key was assigned still picks it up.

Restructuring

ShadeClient was previously an alias for Gateway (ShadeClient = Gateway in __init__.py), while client.py held a different, unrelated ShadeClient wrapping httpx. That placeholder is what this issue replaces:

  • Gateway is now a ShadeClient subclass carrying the payment methods. Every existing Gateway(...) call keeps working unchanged.
  • The httpx-backed transport that occupied client.py moves to http.py as HTTPXTransport, next to SyncHTTPClient and AsyncHTTPClient, leaving client.py for ShadeClient as the issue describes.
  • The two tests asserting ShadeClient is Gateway now assert issubclass(Gateway, ShadeClient).

Incidental fix

http.py used from . import config as _config and then read _config.max_retries off it. That resolved to the Config instance only because __init__.py happened to rebind the shade.config package attribute before importing http. It broke the moment client.py imported http earlier in the chain. Now it imports the instance explicitly (from .config import config as _config).

Fixes #2

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

34 new tests in tests/test_shade_client.py, with an autouse fixture isolating each test from global-config and environment-variable leakage.

  • Isolated instances: parameters bind to the instance, global fallback, instance settings beating global ones, later global changes not mutating an existing client, __repr__ masking the key
  • Coexisting clients: separate credentials, separate settings, and requests actually carrying each client's own Authorization header
  • Resources: explicit client= used over global config, fallback when omitted, two resources on different clients not interfering, a global key set after construction still picked up, AuthenticationError when neither is available
  • default_client(): cached between calls, rebuilt when a global setting changes, raises without a global key
  • from_env(): reads both variables, invalid SHADE_ENVIRONMENT rejected, keyword overrides win, global fallback when unset, raises when nothing is set, empty variable treated as absent
  • Module-level shade.api_key: defaults, assignment, config write-through, and use by clients built without a key

Full suite: 285 passed, 3 skipped, no regressions. Both CI flake8 gates clean on the new code.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Note on scope

The proposed step "each resource class should accept an optional client= kwarg" has no resource classes to apply to yet — shade/resources/ did not exist. This PR adds the package and BaseResource, which establishes that contract; the resource modules themselves (Payments, Invoices, …) are separate issues. The acceptance criterion about resource calls is tested against a minimal BaseResource subclass standing in for a real resource.

Summary by CodeRabbit

  • New Features

    • Added per-client configuration for API keys, environments, timeouts, and retry limits.
    • Added environment-based client setup and shared default-client support.
    • Added reusable resource helpers for synchronous and asynchronous requests.
    • Added masked client representations and package version information.
    • Added improved HTTP transport handling and optional debug logging.
  • Bug Fixes

    • Improved missing-API-key guidance and authentication error messaging.
    • Ensured multiple clients and resources retain the correct credentials independently.

Implements issue ShadeProtocol#2. ShadeClient binds credentials and connection settings
to a single object so a multi-tenant application can hold one client per
merchant instead of mutating the global shade module config.

- ShadeClient takes api_key, environment, api_base, timeout and max_retries,
  each falling back to the matching global setting when omitted. The fallback
  resolves once at construction, so later global changes never mutate an
  existing client.
- Adds a global shade.api_key setting backing that fallback.
- ShadeClient.from_env() builds a client from SHADE_API_KEY and
  SHADE_ENVIRONMENT, with keyword arguments overriding either.
- A missing api_key with no global key set now raises AuthenticationError
  instead of ValueError, naming all three ways to supply one.
- BaseResource gives resources the optional client= kwarg, resolving to the
  shared global client when omitted. The client is resolved per access, so a
  resource built before shade.api_key was assigned still picks it up.

ShadeClient was previously an alias for Gateway, with a separate unrelated
ShadeClient in client.py. Gateway is now a ShadeClient subclass carrying the
payment methods, and the httpx-backed transport that occupied client.py moves
to http.py as HTTPXTransport, alongside the other transports. The two tests
asserting the old alias now assert the subclass relationship.

Also fixes http.py resolving `from . import config` to the config module
rather than the Config instance. That only worked because of the order of
imports in __init__.py, and broke as soon as client.py imported http.py
earlier in the chain.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@daveades, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 81d502cd-0c56-4f09-8653-b7c2a948530f

📥 Commits

Reviewing files that changed from the base of the PR and between 0451235 and 5e297d9.

📒 Files selected for processing (3)
  • src/shade/client.py
  • src/shade/http.py
  • tests/test_client_settings.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codebestia

Copy link
Copy Markdown
Contributor

Hello @daveades
Please resolve the conflicts.

Reconciles ShadeClient with the thread-local global config merged in ShadeProtocol#53.

Resolutions:
- config.py: take main's thread-safe Config and get_config wholesale; the
  api_key global this branch added is already there. Only the missing-key
  message changes, to name all three ways to supply a key.
- client.py: ShadeClient keeps its per-instance role but adopts main's lazy
  resolution. Explicit arguments are pinned to the instance; omitted ones
  resolve against the global config per request, so a missing key surfaces as
  AuthenticationError at request time rather than at construction. Gains
  main's api_key/environment setters, which propagate to the sub-clients.
- gateway.py: Gateway stays a ShadeClient subclass, dropping the constructor
  and accessors now inherited. Keeps main's positional parameter order.
- http.py: take main's dynamic SyncHTTPClient/AsyncHTTPClient; the httpx
  transport this branch moved out of client.py lands as HTTPXTransport and
  resolves through get_config like main's version did.
- __init__.py: drop the "ShadeClient = Gateway" alias, since ShadeClient is
  now a real class, and keep main's other exports.

Tests asserting construction-time snapshotting are rewritten for the lazy
semantics. 353 passed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/shade/resources/base.py (1)

36-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Resource layer reaches into ShadeClient's private _http/_async_http attributes.

Works correctly per the tests, but crosses a module boundary through underscore-prefixed attributes rather than a public accessor on ShadeClient. Consider exposing a small public method (e.g. ShadeClient.request_json(...)/request_json_async(...)) that BaseResource calls instead, so the transport internals stay encapsulated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shade/resources/base.py` around lines 36 - 52, Expose public synchronous
and asynchronous JSON request methods on ShadeClient, such as request_json and
request_json_async, that delegate to the existing HTTP transports. Update
BaseResource._request and _request_async to call these public methods instead of
accessing client._http or client._async_http, preserving the current arguments
and return behavior.
src/shade/client.py (1)

179-191: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Base-url resolution logic duplicated across three classes.

_base_url reimplements the same "instance override → global api_base → environment default" fallback chain that also exists in SyncHTTPClient.base_url, AsyncHTTPClient.base_url, and get_config(). Divergence risk if one copy is updated without the others.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shade/client.py` around lines 179 - 191, Centralize the “instance
override → global api_base → environment default” resolution currently
implemented by _base_url, SyncHTTPClient.base_url, AsyncHTTPClient.base_url, and
get_config(). Update these consumers to reuse one shared resolver while
preserving trailing-slash normalization and the existing api_base property
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/shade/client.py`:
- Around line 99-119: Propagate the client’s debug setting into the resource
request path so the documented logging behavior applies consistently. Update the
SyncHTTPClient and AsyncHTTPClient construction in ShadeClient to support and
receive debug, implementing or reusing the same _should_debug, log_request, and
log_response behavior used by HTTPXTransport; ensure the shade.config.debug
fallback remains effective for resource-backed calls.
- Around line 238-250: Synchronize the first-time initialization in
default_client so concurrent callers cannot both construct ShadeClient
instances. Add or reuse a module-level initialization lock around the
_default_client None check and assignment, while keeping the existing
cached-client behavior for subsequent calls and ensuring only the stored client
owns its httpx.Client.

In `@src/shade/http.py`:
- Around line 399-465: Update HTTPXTransport.__init__ and ShadeClient’s
transport construction to accept and forward the resolved timeout and
max_retries values into the HTTPX client. Modify HTTPXTransport.request to apply
the configured timeout and retry failed requests using the existing
SyncHTTPClient/AsyncHTTPClient retry behavior, while preserving request/response
logging and returning the final response.

---

Nitpick comments:
In `@src/shade/client.py`:
- Around line 179-191: Centralize the “instance override → global api_base →
environment default” resolution currently implemented by _base_url,
SyncHTTPClient.base_url, AsyncHTTPClient.base_url, and get_config(). Update
these consumers to reuse one shared resolver while preserving trailing-slash
normalization and the existing api_base property behavior.

In `@src/shade/resources/base.py`:
- Around line 36-52: Expose public synchronous and asynchronous JSON request
methods on ShadeClient, such as request_json and request_json_async, that
delegate to the existing HTTP transports. Update BaseResource._request and
_request_async to call these public methods instead of accessing client._http or
client._async_http, preserving the current arguments and return behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a2608d09-8978-4ec1-9540-a33824031c4b

📥 Commits

Reviewing files that changed from the base of the PR and between ff36ffa and 0451235.

📒 Files selected for processing (10)
  • src/shade/__init__.py
  • src/shade/client.py
  • src/shade/config.py
  • src/shade/gateway.py
  • src/shade/http.py
  • src/shade/resources/__init__.py
  • src/shade/resources/base.py
  • tests/test_api_base.py
  • tests/test_client_settings.py
  • tests/test_shade_client.py

Comment thread src/shade/client.py
Comment thread src/shade/client.py Outdated
Comment thread src/shade/http.py
Comment on lines +399 to +465
class HTTPXTransport:
"""httpx-backed transport returning raw responses, with debug logging.

Used by :class:`~shade.client.ShadeClient` for calls that need the whole
response (headers, streaming, non-JSON bodies) rather than a decoded body.
Credentials and the target host are resolved per request, so a client left
on the global defaults follows later changes to ``shade.api_key`` and
friends. Logging is enabled per-instance via ``debug`` or globally via
``shade.config.debug``, and the ``Authorization`` header is masked either way.
"""

def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
environment: Optional[Environment | str] = None,
debug: bool = False,
http_client: Optional["httpx.Client"] = None,
) -> None:
self.api_key = api_key
self._base_url = base_url.rstrip("/") if base_url else None
self.environment = environment
self.debug = debug
self._http = http_client or httpx.Client()
self._owns_http_client = http_client is None

def close(self) -> None:
if self._owns_http_client:
self._http.close()

def _should_debug(self) -> bool:
return self.debug or _config.debug

def request(
self,
method: str,
path: str,
*,
headers: Optional[Mapping[str, str]] = None,
json: Any = None,
content: Optional[bytes] = None,
) -> "httpx.Response":
cfg = get_config(
api_key=self.api_key,
environment=self.environment,
api_base=self._base_url,
)

normalized_path = path if path.startswith("/") else f"/{path}"
url = f"{cfg.base_url}{normalized_path}"
request_headers = {"Authorization": f"Bearer {cfg.api_key}", **(headers or {})}

if self._should_debug():
log_request(method, url, request_headers, content if content is not None else json)

response = self._http.request(
method,
url,
headers=request_headers,
json=json,
content=content,
)

if self._should_debug():
log_response(response.status_code, response.headers, response.text)

return response

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Does httpx.Client() apply a default 5 second timeout when no timeout argument is passed to the constructor?

💡 Result:

Yes, httpx.Client applies a default 5-second timeout when no timeout argument is passed to the constructor [1][2]. The HTTPX library is designed to enforce timeouts by default for all network operations to prevent requests from hanging indefinitely [3]. When you initialize a client without a specific timeout argument, it defaults to a configuration equivalent to httpx.Timeout(5.0) [1][4][2]. This default timeout applies to all requests made with that client instance unless explicitly overridden on a per-request basis [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg '(^|/)(client|http)\.py$' || true

echo
echo "http.py outline:"
ast-grep outline src/shade/http.py --view compact || true

echo
echo "client.py outline:"
ast-grep outline src/shade/client.py --view compact || true

echo
echo "Relevant http.py lines 350-475:"
sed -n '350,475p' src/shade/http.py | nl -ba -v350

echo
echo "Relevant client.py ShadeClient/HTTPXTransport references:"
rg -n "HTTPXTransport|ShadeClient|timeout|max_retries|_client" src/shade/client.py src/shade/http.py

Repository: ShadeProtocol/shade-python

Length of output: 623


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant http.py lines 350-475:"
sed -n '350,475p' src/shade/http.py

echo
echo "Relevant client.py lines 1-260:"
sed -n '1,260p' src/shade/client.py

echo
echo "Retry/timeout references:"
rg -n "max_retries|timeout|HTTPXTransport|SyncHTTPClient|AsyncHTTPClient|request\(" src/shade/client.py src/shade/http.py

Repository: ShadeProtocol/shade-python

Length of output: 19742


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "SyncHTTPClient request and timeout handling:"
sed -n '476,608p' src/shade/http.py

echo
echo "AsyncHTTPClient timeout setup:"
sed -n '700,750p' src/shade/http.py

echo
echo "Config defaults/types:"
rg -n "timeout|max_retries|validate_client_settings|client_settings|CONFIG" src/shade/config.py src/shade/__init__.py
sed -n '1,220p' src/shade/config.py
sed -n '1,160p' src/shade/__init__.py

Repository: ShadeProtocol/shade-python

Length of output: 21510


Wire timeout and max_retries into HTTPXTransport.

ShadeClient constructs HTTPXTransport without forwarding timeout/max_retries, and HTTPXTransport accepts neither before creating httpx.Client() with httpx’s built-in timeout and making a single self._http.request(...) call. As a result, ShadeClient(request(...), timeout=..., max_retries=...) is silently ignored. Pass the resolved config values into the transport/client and use a retry loop similar to SyncHTTPClient/AsyncHTTPClient.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shade/http.py` around lines 399 - 465, Update HTTPXTransport.__init__ and
ShadeClient’s transport construction to accept and forward the resolved timeout
and max_retries values into the HTTPX client. Modify HTTPXTransport.request to
apply the configured timeout and retry failed requests using the existing
SyncHTTPClient/AsyncHTTPClient retry behavior, while preserving request/response
logging and returning the final response.

…ransport

Two findings from review on the merge:

default_client() had an unsynchronized check-then-set, so concurrent first
calls could each build a client and leak the loser's httpx connection pool.
Guard it with a lock, and close the client that reset_default_client() drops.

HTTPXTransport created its httpx client without a timeout, so ShadeClient's
timeout was silently ignored for requests going through it -- httpx applied
its own 5s default instead. Resolve the timeout with the rest of the config
and pass it per request.

Wiring max_retries into the transport needs a retry loop of its own and is
left alone, as is debug reaching SyncHTTPClient/AsyncHTTPClient; both gaps
predate this branch.
@daveades

Copy link
Copy Markdown
Contributor Author

@codebestia
Conflicts resolved

@codebestia codebestia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!
Thank you for your contribution

@codebestia
codebestia merged commit 0e33b54 into ShadeProtocol:main Jul 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement ShadeClient class for per-instance configuration

2 participants