Last reviewed: November 1, 2026 (Asia/Shanghai).
Direct answer
Keep CometAPI credentials in a fresh outbound request or request-specific authentication object. Reuse an HTTPX client for its transport, but never switch client.headers["Authorization"] between tenants or accounts while handlers share that client. Resolve an authorized account binding on the server, capture its credential version, and construct the request from that binding without changing shared defaults.
The HTTPX Clients guide
documents that client configuration applies across requests and that headers, query parameters, and cookies merge with request-level values. A handler can therefore inherit state it did not supply. The Developer Interface reference
explicitly permits sharing an AsyncClient between tasks; that does not make a changing application-wide credential belong to the correct task.
For example, handler A selects credential A and waits for admission. Handler B replaces the client’s default authorization with credential B. When A subsequently builds its request, it can send B’s credential. The provider may accept that request, so a successful HTTP response does not establish that the intended account was used. This is an application-state defect to prevent before dispatch.
Who this is for
This guide is for Python gateway maintainers serving several authorized CometAPI accounts or customer-supplied credential bindings in one worker. It also applies when a staging and production integration accidentally share a client. The concurrency check requires Python 3.11 or later and HTTPX; it uses dummy credentials and an in-memory transport.
A product deliberately using one service credential for all tenants still needs tenant authorization and data isolation. Separate keys alone do not implement those controls, and this article does not assume that CometAPI supplies your application’s tenant policy.
Key takeaways
- Keep the credential, model selection, and credential-version reference in one request-owned binding.
- Authorize the account selection before looking up its secret; a browser tenant label is insufficient.
- Avoid shared header mutation, including temporary mutation followed by restoration.
- Check inherited cookies, query parameters, authentication objects, and hooks alongside headers.
- Test forced interleaving with two distinguishable dummy credentials.
- Retain a reusable client with a deliberate startup, shutdown, timeout, and egress policy.
Contract details to verify
Resolve identity before selecting the credential
The OWASP Multi-Tenant Application Security Cheat Sheet says client-supplied tenant identifiers are selectors: the authenticated principal must be authorized for the selected tenant. It recommends binding tenant context to a server-verified identity and current membership or service authorization.
Apply that rule to the outbound account. Authenticate the caller, authorize the tenant and requested operation, then resolve a server-controlled credential reference. Retrieve the secret through your approved mechanism. Do not let an arbitrary request header select a secret-store path, and do not forward the browser’s inbound Authorization header as the provider credential.
Capture the binding for the attempt after any necessary permission check. If queued work can wait long enough for membership or credential validity to change, re-establish authorization before dispatch. Preserve the binding’s non-secret version reference so diagnostics can distinguish an old credential from a different account. The site’s key-rotation guide covers adoption and retirement across workers; this guide addresses isolation between requests within a worker.
| State | Intended scope | Verification |
|---|---|---|
| HTTP connection pool, approved proxy and CA policy | Owning application or worker | Construct once; drain and close during shutdown |
| Tenant authorization and account selection | Logical operation | Derived from verified identity and permitted scope |
| Credential value and version | Outbound attempt | Resolved together and kept out of shared mutable defaults |
| Messages and provider headers | Fresh request | Constructed from the authorized operation without arbitrary inbound forwarding |
| Completion and error routing | Same operation | Result cannot be reassigned using a later global tenant value |
HTTPX clients also persist cookies. That matters if a wrapper silently begins relying on a cookie jar, even when the integration was designed around bearer authentication. Document whether cookies are part of the outbound contract. The example below excludes client-default cookies from request construction; it does not remove HTTPX’s cookie-storage feature or sanitize every possible hook.
Build an explicit request without tenant defaults
The CometAPI Chat Completions reference
documents POST /v1/chat/completions, bearer authentication, and required model and messages fields. Save this request builder as credential_boundary.py:
from dataclasses import dataclass, field
import httpx
CHAT_URL = "https://api.cometapi.com/v1/chat/completions"
@dataclass(frozen=True)
class ChatBinding:
tenant_ref: str
credential_version: str
model: str
value: str = field(repr=False)
def make_chat_request(binding: ChatBinding, prompt: str) -> httpx.Request:
public_fields = (
binding.tenant_ref, binding.credential_version, binding.model
)
if any(not isinstance(v, str) or not v.strip() for v in public_fields):
raise ValueError("invalid_chat_binding")
value = binding.value
if (
not isinstance(value, str) or not value or not value.isascii()
or any(ch.isspace() for ch in value)
):
raise ValueError("invalid_chat_credential")
if not isinstance(prompt, str) or not prompt.strip():
raise ValueError("invalid_chat_prompt")
return httpx.Request(
"POST", CHAT_URL,
headers={
"Authorization": f"Bearer {value}",
"Accept": "application/json",
},
json={
"model": binding.model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
},
)
Construct ChatBinding only inside trusted application code after authorization. A frozen dataclass discourages accidental reassignment; it is not an authorization mechanism. repr=False omits the key from the generated representation, but serialization, exception handlers, debuggers, and request logging can still expose it. Never log the binding or complete request.
Use a model approved for that binding. The builder intentionally avoids choosing a model ID, rate limit, or token allowance for the reader. Add the selected model’s supported output controls, prompt limits, and response-size policy in the enclosing adapter.
Create a reusable client with the application’s approved transport settings. Once admission and request construction are complete, dispatch using:
response = await client.send(request, auth=None, follow_redirects=False)
response.raise_for_status()
The Clients guide supports explicit httpx.Request objects as an alternative to merged configuration. The Developer Interface documents send() and its request-level authentication and redirect controls. Here, auth=None prevents a client authentication object from replacing the explicit bearer header. The fixed absolute URL avoids inheriting an account-specific base URL, and constructing the request directly avoids the client’s header, query-parameter, and cookie merge.
This choice intentionally bypasses convenient client defaults. Add any required non-secret application headers explicitly. Keep transport TLS, proxy policy, connection limits, and client timeouts configured on the client; keep the user’s remaining overall deadline around the operation. Audit request hooks and custom transports, because they can still inspect or alter a request after construction.
raise_for_status() is only the HTTP gate. Before delivering output, validate the Chat completion envelope and the application’s accepted finish reason, refusal, tool-call, and content branches. Send the result back through the originating operation’s context. This builder establishes credential placement; the surrounding adapter still owns response validation, bounded reads, and cancellation.
Happy-path operator workflow
Trace one real handler from authenticated ingress to outbound dispatch. Record where it authorizes the account, retrieves the credential, captures the version, constructs the body, and associates the response with its consumer. Search wrappers and failure branches for assignments to client.headers, client.auth, client-level parameters, or globals called “current tenant.” Inspect the actual constructor as well as the handler.
Move account-dependent state into a fresh request. Preserve the shared client lifetime so the change does not create a new connection pool for every call. If using ordinary client.post(..., headers=...) instead, prove that defaults contain no account-dependent state and every path supplies the intended authorization. Merely adding a second header does not erase other merged defaults.
Run the regression check below, then exercise the deployed adapter with a controlled upstream recorder. Observe only dummy account markers and sanitized outcomes. Require both requests to retain their intended binding under overlap. Keep the site’s per-tenant admission controls in place: isolating authorization does not allocate shared pool capacity fairly.
Error-path operator workflow
If a request may have used the wrong binding, hold affected dispatch paths and investigate the shared-state boundary. Keep the operation identifier, authorized account reference, credential-version reference, code revision, and dispatch outcome in restricted diagnostics. Exclude secret values, complete authorization headers, prompts, response bodies, and raw request or exception objects from routine logs.
An authentication failure should enter credential remediation. Do not try a different tenant’s key or fall back to a global service key merely to obtain a successful response. If the request was already sent under an unintended binding, its effects and charges require reconciliation; rebuilding the request does not make an automatic replay safe. Follow your incident process for possible unauthorized account use or content exposure.
For missing credentials or denied membership, reject before network dispatch. For cancellation, preserve the existing operation outcome and suppress further attempts after the caller’s action ends. Keep individual response cleanup separate from whole-client shutdown; the site’s HTTPX response-lifetime guide covers streaming cleanup.
Verify credential isolation with forced interleaving
Save this as credential_probe.py beside the builder and run python3 credential_probe.py in an environment with HTTPX installed. All requests are intercepted by MockTransport. The synthetic model and credentials are fixtures, not provider inputs to copy into a live call.
import asyncio
import json
import httpx
from credential_boundary import CHAT_URL, ChatBinding, make_chat_request
async def trial(request_local):
seen = {}
first_ready = asyncio.Event()
release_first = asyncio.Event()
async def receive(request):
label = json.loads(request.content)["messages"][0]["content"]
seen[label] = request.headers["Authorization"]
if request_local:
assert "Cookie" not in request.headers
assert "X-Tenant-ID" not in request.headers
assert request.url.query == b""
return httpx.Response(200, json={"fixture": "accepted"})
async with httpx.AsyncClient(
transport=httpx.MockTransport(receive)
) as client:
if request_local:
client.headers["Authorization"] = "[REDACTED]"
client.headers["X-Tenant-ID"] = "stale-tenant"
client.cookies.set("session", "stale-cookie")
client.params = {"account": "stale-account"}
client.auth = ("[REDACTED]", "[REDACTED]")
async def worker(label):
binding = ChatBinding(label, "test-v1", "fixture-model", f"dummy-{label}")
if request_local:
request = make_chat_request(binding, label)
else:
client.headers["Authorization"] = f"Bearer {binding.value}"
if label == "A":
first_ready.set()
await release_first.wait()
else:
release_first.set()
if request_local:
response = await client.send(
request, auth=None, follow_redirects=False
)
else:
response = await client.post(
CHAT_URL,
json={"messages": [{"role": "user", "content": label}]},
)
response.raise_for_status()
async with asyncio.TaskGroup() as group:
group.create_task(worker("A"))
await first_ready.wait()
group.create_task(worker("B"))
if request_local:
before = dict(seen)
try:
make_chat_request(ChatBinding("A", "test-v1", "fixture-model", ""), "A")
except ValueError:
pass
else:
raise AssertionError("missing_credential_was_accepted")
assert seen == before
return seen
async def main():
async with asyncio.timeout(5):
bad = await trial(False)
good = await trial(True)
assert bad == {"A": "Bearer dummy-B", "B": "Bearer dummy-B"}
assert good == {"A": "Bearer dummy-A", "B": "Bearer dummy-B"}
print("shared-header contamination reproduced: True")
print("request-local credentials and default-state checks passed: True")
asyncio.run(main())
The events deliberately pause A after its credential selection, then let B change the shared default before A builds the unsafe request. This makes the regression reproducible without hoping that a stress test hits the right timing. The corrected path constructs an independent request and sends it with explicit authentication behavior.
On November 1, 2026, a local run with Python 3.12.3 and HTTPX 0.28.1 produced both printed True results. Both unsafe requests carried dummy B; the corrected requests carried their respective dummy credentials despite the seeded defaults. Separate local checks rejected empty, whitespace-containing, newline-containing, and non-string credentials, omitted the key from the dataclass representation, and stopped a redirect after one send. No authenticated CometAPI call, real socket, production middleware, or provider account-isolation test was performed.
For deployment acceptance, also test a denied account selection, a missing secret, failure after dispatch, cancellation during admission, and a credential-version change. Require rejected bindings to produce zero sends. Repeat through the actual wrappers and hooks, since the in-memory check cannot prove those components preserve the boundary.
Failure modes
Temporary default changes overlap. A try/finally that restores the old header still exposes the intervening value to another handler. Eliminate shared mutation rather than relying on restoration timing.
A request header is mistaken for complete isolation. Cookies, account parameters, client auth, or hooks still carry another account’s state. Inspect the final synthetic request at the transport boundary.
The binding is correct but its selection is unauthorized. Request-local storage preserves the wrong account faithfully. Verify membership and operation permission before resolving the secret.
An error path substitutes a more privileged key. A malformed or missing binding becomes a successful call under a service account. Make that failure terminal until authorization is repaired.
Every request creates a client. The credential mixup disappears while connection reuse and capacity deteriorate. Retain a reusable transport with stable defaults and close it during its owning lifecycle.
FAQ
Is sharing an AsyncClient unsafe?
HTTPX supports sharing it between tasks. Keep transport configuration stable and put account-dependent values in request-owned state. Review authentication objects and hooks separately if they maintain mutable state.
Can request-level headers be sufficient?
Yes, with a dedicated client whose defaults, cookies, authentication, and hooks cannot introduce another account’s state. The explicit-request pattern makes the absence of merged headers, query parameters, and cookies easier to test.
Does an explicit Request bypass every client behavior?
No. The client still selects the transport, applies authentication behavior and hooks, handles responses, and owns its pool. Set dispatch options deliberately and test custom components. An explicit request is not a general security sandbox.
Does one API key per tenant replace tenant authorization?
No. The application must authorize which principal may use each binding and deliver each result only to the permitted consumer. A deliberately shared service identity needs that application boundary too.
Reader next step
Audit one shared HTTPX client and remove account-dependent mutations from its request paths. Run the two-binding probe, then verify the same boundary through the deployed adapter with synthetic inputs. Promote only after denied bindings produce no sends and overlapping valid calls retain the intended credential and response owner.
When those checks pass, Start with CometAPI and run a small, authorized staging request using a server-resolved binding and an approved model.
Sources checked
- HTTPX: Clients — fetched November 1, 2026. Supports connection reuse, shared configuration, cookie persistence, configuration merging, and explicit request construction.
- HTTPX: Developer Interface
— fetched November 1, 2026. Supports task sharing for
AsyncClient, request construction and sending, and request-level authentication and redirect arguments. - CometAPI: Create a chat completion
— fetched November 1, 2026. Supports the fixed API endpoint, bearer security scheme, and required
modelandmessagesfields. - OWASP: Multi-Tenant Application Security Cheat Sheet — fetched November 1, 2026. Supports verified tenant context, authorization of selected tenants, and explicit credential scopes.
These maintained primary references support an evergreen integration defect. The binding design, containment workflow, and deployment acceptance criteria are engineering recommendations. The local observations establish the stated HTTPX behavior on the tested version, not a provider incident or a guarantee about every deployed adapter.