Status: Implementation in progress
Implementation progress
Context
The Python SDK does not currently have an API module comparable to the Go SDK's api/ package. HTTP transport, authentication, endpoint calls, response parsing, retries, and product-level fallback behavior are spread across logger.py, functions/invoke.py, framework2.py, sandbox.py, the devserver, and CLI modules.
This became especially visible in issue #639: Experiment.summarize() catches any failure from experiment-comparison2 and returns empty score and metric maps. That makes a failed read look like a successful experiment with no scores.
An audit of the Python SDK's Braintrust-directed HTTP calls found a broader transport problem:
- Before login, connections have no default retries.
- After login,
RetryRequestExceptionsAdapter retries request exceptions for every HTTP method, including unsafe POST requests — unless the user installed a custom adapter via set_http_adapter(), in which case make_long_lived() is a no-op and SDK-managed retries are replaced entirely.
- It does not retry completed 429 or 5xx responses.
- Retry behavior cannot be inferred from the HTTP method because several logical reads, including BTQL and legacy lookup endpoints, use POST.
- Some call sites have their own retry or fallback behavior, while others raise immediately.
- Several backend write endpoints are not idempotent, so adding global status retries would create duplicate work or misleading failures.
The SDK should centralize endpoint definitions and make retry and fallback behavior explicit per operation.
Goals
- Give each Braintrust endpoint one authoritative SDK implementation for request shape, response parsing, errors, and retry safety.
- Retry transient failures for safe reads, including logical reads implemented with POST.
- Avoid automatically replaying operations that can create duplicate experiments, invoke functions twice, incur provider costs, or produce duplicate versions.
- Preserve HTTP status, request ID, attempt count, and exception causes in structured errors.
- Separate transport failures from product-level decisions such as cache fallback or lenient eval reporting.
- Keep existing high-level APIs such as
braintrust.init(), Experiment.summarize(), and load_prompt() compatible while migrating them internally: signatures, return types, and serialized shapes stay backward compatible (using deprecated bridge fields where a model gains a new authoritative representation). Intentional behavior changes, such as strict-by-default summary failures, ship only in dedicated PRs with release notes.
- Preserve custom transport injection for tests, VCR, and users of
set_http_adapter().
- Provide a resource-oriented public client under
braintrust.api.
- Prefer realistic integration and VCR-backed coverage over mocked sessions, responses, transports, or service clients.
- Make the migration incremental and independently testable rather than a large rewrite.
Non-goals
- Replacing the SDK's high-level experiment, dataset, prompt, or logging APIs.
- Moving provider integration HTTP traffic into this client.
- Immediately generating a complete client from the OpenAPI specification.
- Applying one retry policy to every POST request.
- Rewriting background log ingestion in the first phase.
- Providing complete coverage of every OpenAPI endpoint in the first release of
braintrust.api.
Current architecture
BraintrustState owns three HTTPConnection instances:
- app/control-plane connection;
- API/data-plane connection;
- proxy connection.
HTTPConnection and RetryRequestExceptionsAdapter are defined in logger.py. Endpoint calls are distributed across the package.
The Go SDK provides a useful organizational model:
api/client.go
api/projects/
api/experiments/
api/datasets/
api/functions/
internal/https/client.go
Its main strengths are the resource-oriented service layer and typed HTTP errors. The Python implementation should adopt those ideas, but add operation-level retry policies rather than copying the Go transport directly.
HTTP call-site audit summary
Safe logical reads
These operations can retry transient network failures, 408, 429, and transient 5xx responses:
- API-key login and organization discovery;
ping and API version discovery;
- project, experiment, dataset, prompt, and function lookups;
- prompt and parameter loading;
- BTQL queries;
- environment resolution;
- base-experiment lookup;
- experiment comparison and dataset summary;
- attachment metadata and downloads.
Some of these are POST requests even though they are reads, so an HTTP-method-only policy is insufficient.
Some reads also translate specific statuses into domain results today, and the migration must preserve those translations explicitly. In particular, base-experiment lookup (POST /api/base_experiment/get_id on the app connection) treats HTTP 400 as "no base experiment" and returns None. A fresh project's first eval always takes this path, so migrating the endpoint to raise typed errors for all 4xx responses would break it; ExperimentsAPI must keep the 400-to-None rule as a documented status-to-domain translation.
Idempotent or conditionally idempotent writes
- Plain project registration is a get-or-create operation by organization and name. This classification does not automatically extend to registration variants with secondary resources such as agents.
- Named dataset registration with
update=False resolves name conflicts to the existing dataset. update=True reapplies the update but may duplicate audit side effects.
- Named experiment registration with
update=True returns the existing experiment and is conditionally replay-safe. Registration without an explicit name, or with update=False, is not.
- Attachment status updates overwrite the same status object.
- A code-bundle PUT to an already-issued signed URL allows overwrite.
These operations may opt into retries, but the policy should be explicit and should account for secondary effects such as audit records.
Attachment operations that require reconciliation
The current backend contract does not make the whole attachment workflow transparently replayable:
POST /attachment first creates <attachment-key>.status.json with allowOverwrite: false. Repeating the request for the same client-generated key returns HTTP 409 instead of another signed upload URL. A lost successful initialization response therefore cannot be recovered by blindly replaying the POST, so this operation cannot use generic status retries under the current contract.
- The initialization response includes
If-None-Match: * for the signed upload PUT, and the object-store URL is issued with overwrite disabled. The SDK also adds x-ms-blob-type: BlockBlob for Azure. Replaying an upload after an ambiguous success is non-destructive, but it can return an object-store precondition failure rather than the original success.
GET /attachment checks both the status object and uploaded object. A specialized attachment workflow can use contentLength/download metadata to establish that the object exists after an ambiguous upload. If the original status object still says uploading, that status currently takes precedence, so the client may need to write done; the backend does not automatically report done merely because the object exists. This is workflow-level reconciliation, not a reason to classify either initialization or upload as an ordinary IDEMPOTENT_WRITE.
Under the current contract, attachment initialization uses RetryMode.NONE. Upload and status handling remain specialized and explicitly interpret precondition failures and metadata/status reads.
Unsafe operations
The following should not be replayed automatically without an idempotency mechanism:
- experiment registration without both an explicit name and
update=True, because the backend creates a new generated or uniquely suffixed experiment;
- function invocation, because it can incur cost and execute arbitrary side effects;
- sandbox listing, because it creates a sandbox and runs code;
- requesting a code-bundle upload, because the backend allocates a new bundle ID and random object path;
- inserting or replacing functions, prompts, and parameters, because a replay can fail with an existing slug or create another version;
- organization updates, unless duplicate audit effects are acceptable.
Specialized at-least-once operations
Background /logs3 ingestion already has batching and its own retry loop. Each backend execution gets a new transaction ID, so retries are at-least-once rather than truly idempotent. Overflow upload setup can also leave unused objects after retries.
Logging should retain a dedicated policy and should not accidentally gain a second generic retry loop.
Existing fallback behavior
Transport retries do not resolve ambiguous fallback semantics. Current examples include:
- experiment summary returning empty scores and metrics after any final comparison failure;
- prompt and parameter loading returning cached data after an unpinned request fails;
- pinned prompt requests converting infrastructure failures into "not found" errors;
- API version discovery caching a default payload limit for the process after one failure, which also memoizes
can_use_overflow: False and disables overflow uploads for the rest of the process without ever re-probing;
- the explicit comparison-name lookup in
Experiment.summarize() swallowing any exception and continuing without a comparison name;
- named base-experiment registration warning and continuing without the requested baseline;
- asynchronous log ingestion dropping a batch after retries.
The API layer should report errors. Higher layers should own and clearly mark any fallback.
Backend contract assumptions
The architecture and endpoint policies must be checked against the backend contract, not inferred from HTTP verbs or endpoint names. The current backend establishes these details:
GET or POST /api/apikey/login returns org_info entries with id, name, api_url, proxy_url, realtime_url, is_universal_api, and Git metadata settings. The control plane currently fills a missing api_url from the deployment default and a missing proxy_url from the resolved API URL, but the Python router should still tolerate null or omitted values for older and self-hosted deployments.
- The data plane serves native data-plane routes and proxies control-plane
GET/POST routes matching /api/:object_type and /api/:object_type/:action to its configured app origin. Bootstrap still has to contact the configured app origin because the organization-specific API URL is not known before login.
- Data-plane authentication expects
Authorization: Bearer <token>. Proxy operations such as function invocation also require operation-specific headers such as x-bt-org-name; these belong in the service endpoint definition rather than in a global transport default.
- Data-plane responses expose
x-bt-internal-trace-id when tracing is active. Control-plane infrastructure may expose a deployment request ID such as x-vercel-id. Structured errors should capture the recognized response identifier and retain the relevant response headers rather than assuming a single universal request-id header.
- Error bodies are not uniform during this migration: data-plane errors commonly use
Code/Message, while control-plane errors may use error. Services must use status codes for control flow and treat the parsed body as diagnostic context, not depend on one error-body schema.
- Backend response objects evolve additively. Request models can reject invalid input, but response parsing must tolerate unknown fields (and preserve them where the public model promises round-tripping) so a newly added backend field does not break an older SDK.
- The
/v1/:object_type facade maps writes onto legacy registration semantics. Project POST is get-or-create. Dataset POST sends update=False, while dataset PUT sends update=True. Experiment POST/PUT send update=True unless ensure_new=True; a named experiment is therefore get-existing by default, but an unnamed experiment or ensure_new=True still creates a new experiment. Retry policy must be selected from those effective semantics and payload fields, not from POST versus PUT.
- Legacy base-experiment lookup returns HTTP 400 when no usable baseline exists. That status is a domain-level absence only for this operation; it is not a general not-found convention.
- Attachment initialization and upload have the create-only/precondition behavior described above. The client must not describe them as overwrite operations.
These assumptions were checked against app/pages/api/apikey/login.ts, app/pages/api/{project,dataset,experiment}/register.ts, app/pages/api/base_experiment/get_id.ts, api-ts/src/api.ts, api-ts/src/attachment.ts, api-ts/src/proxy/register-code.ts, and app-schema/schema.sql in the Braintrust backend repository. They should have contract tests against the local backend or VCR recordings. If the backend contract changes, update this section and the corresponding endpoint policy together.
Proposed package structure
Use the public braintrust.api namespace from the beginning. Public client, service, model, error, and policy types live directly in this package; low-level implementation details use underscore-prefixed modules:
src/braintrust/api/
__init__.py
client.py
errors.py
policies.py
_routing.py
_transport.py
auth.py
projects.py
experiments.py
datasets.py
prompts.py
functions.py
queries.py
attachments.py
models/
projects.py
experiments.py
datasets.py
functions.py
braintrust.api.__init__ should explicitly export the supported public surface. _transport.py remains private in the same way that the Go SDK keeps its HTTP implementation under internal/https.
Transport layer
The policy-driven client's end state should use one configured transport and one underlying requests.Session for Braintrust app, API, and proxy targets. The initial public resource client is synchronous; it does not expose thread-wrapped pseudo-async methods. Existing background workers remain responsible for queued log ingestion and continue running blocking HTTP away from the caller. An endpoint router supplies the destination URL for each request; separate Braintrust destinations do not require separate transport objects. During incremental migration, legacy HTTPConnection sessions may need to coexist until their call sites and mutable adapter behavior have moved; forcing every compatibility shim onto the shared session in PR 3 would not be behavior-preserving.
The transport is responsible only for HTTP mechanics:
- resolving a target through the endpoint router and joining its URL;
- authorization and common headers;
- timeout handling;
- request and response debug logging;
- bounded retries according to an explicit policy;
- status checking;
- JSON decoding;
- structured errors;
- custom
requests.Session or adapter injection.
Per-attempt timeout is configured only through RetryPolicy, so timeout and elapsed-budget validation have one source of truth. A transport closes sessions it creates and supports context-manager cleanup; an injected session remains owned by its caller.
It must not implement domain fallbacks such as returning cached prompts or empty experiment summaries.
Session sharing constraints
Consolidating three sessions into one introduces constraints the current architecture satisfies by construction:
- Thread safety. The session is shared between user threads and the background logger's thread pool (
HTTP_REQUEST_THREAD_POOL, sized to cpu_count()). requests.Session is not safe for concurrent mutation: session-level headers and the cookie jar must be treated as immutable after bootstrap. Authorization and token refresh must be applied per request (or per target) rather than by mutating session.headers the way set_token() does today, and lazy client construction must be guarded against concurrent initialization.
- Credential scoping. Authorization is currently a session-default header on each Braintrust connection, while object-storage requests deliberately use separate token-less connections. With one session, the Authorization header must be attached only to requests routed to Braintrust targets. Absolute signed object-storage URLs (S3, GCS, Azure) must never receive the Braintrust API key — sending it there is a credential exposure, and stray headers can break Azure signed-URL requests.
- Cookie isolation. Three separate sessions currently prevent cookies set by one host from being replayed to another. Although a normal cookie jar scopes cookies by domain, Braintrust API-key traffic does not require persisted cookies; the shared transport should reject or clear response cookies so concurrent cookie-jar mutation and accidental cross-target state are impossible.
- Adapter scope. A
requests.Session selects adapters by URL prefix, not by logical RequestTarget. Legacy connections remain isolated until endpoint policies replace their timing-dependent mutation quirks. In the final client, a custom adapter applies consistently to app, API, proxy, and signed-object requests regardless of installation timing. Injecting a session or adapter disables the SDK retry loop by default; that transport owner assumes responsibility for replay safety. A caller may explicitly enable the SDK loop only when it knows the injected adapters do not retry.
- Streaming.
RetryRequestExceptionsAdapter.send() fully downloads response.content before returning, even for stream=True requests, so streamed proxy function invocations are fully buffered in memory today. The new transport must support true streaming; un-buffering invocation responses is a user-visible behavior change and is handled explicitly in PR 9.
A conceptual interface is:
class RetryMode(enum.Enum):
NONE = "none"
SAFE_READ = "safe_read"
IDEMPOTENT_WRITE = "idempotent_write"
LOG_INGESTION = "log_ingestion"
class Transport:
def request(
self,
target: RequestTarget,
method: str,
path: str,
*,
params: Mapping[str, Any] | None = None,
json: Any = None,
data: Any = None,
headers: Mapping[str, str] | None = None,
retry_mode: RetryMode = RetryMode.NONE,
stream: bool = False,
) -> requests.Response:
...
def request_json(self, ...) -> Any:
...
Retry mode must be supplied by the endpoint implementation rather than inferred only from the HTTP method. For example, BTQL is a safe logical read even though it uses POST, while function invocation is not retryable even though it also uses POST.
RequestTarget covers Braintrust destinations only. Absolute signed object-storage URLs are deliberately not expressible through Transport.request(); they go through the specialized object-storage path described under "Specialized clients", which guarantees the Braintrust Authorization header is never attached.
Retry behavior
SAFE_READ and explicitly approved IDEMPOTENT_WRITE operations should support:
- connection failures and timeouts;
- HTTP 408;
- HTTP 429 with
Retry-After;
- transient 5xx responses;
- bounded exponential backoff when the server does not provide a retry delay;
- a maximum attempt count and elapsed-time budget;
- response cleanup before retry;
- deterministic test hooks for sleep and clocks.
Ordinary 4xx responses, JSON decoding failures, schema validation failures, and deterministic request-construction failures such as invalid URLs, schemas, or headers should not retry by default. Only transient request exceptions such as connection failures and timeouts are eligible. The default retryable 5xx set is exactly 500, 502, 503, and 504; statuses such as 501, 505, 507, 508, and 511 are not retried unless an endpoint declares an explicit, evidence-based override adding them (overrides may also shrink the set). A retryable 503 with Retry-After follows the same server-delay and budget rules as 429.
The SDK retry loop must also be able to reproduce the request body. JSON bodies and immutable str/bytes data may retry. File-like bodies, generators, multipart files, and other potentially consumed data are sent once unless a specialized workflow prepares and rewinds them explicitly.
The default SAFE_READ policy allows four total attempts and 60 seconds of total elapsed time, including request execution and sleeps. Elapsed time uses a monotonic clock, and each request timeout and sleep is clamped to the remaining budget.
The per-attempt timeout must be materially below the elapsed budget, or timeout retries are structurally impossible: today's default request timeout is 60 seconds (BRAINTRUST_HTTP_TIMEOUT, applied in make_long_lived()), and under a 60-second budget a first-attempt read timeout would exhaust the entire budget, so timeouts — first on the retryable list — would never actually retry. Therefore:
- the default
SAFE_READ per-attempt timeout is 20 seconds, so a first-attempt timeout leaves budget for at least two more attempts;
NONE operations keep the current 60-second default timeout; their behavior is unchanged because they never retry;
- endpoints with a legitimate longer runtime, such as BTQL or experiment comparison, declare an explicit override with both a longer per-attempt timeout and a proportionally larger budget (for example, 60 seconds per attempt within a 180-second budget) rather than weakening the global default;
- policy construction should reject or flag a configuration whose elapsed budget is less than or equal to its per-attempt timeout, and tests should assert that a first-attempt timeout still leaves at least one retry.
HTTP 429 and Retry-After
A 429 response may only retry when the operation's retry mode permits replay. NONE operations, such as function invocation, must surface the 429 without replaying the request.
For retryable operations, the client must respect Retry-After:
- support both RFC forms: delay-seconds (for example,
Retry-After: 5) and an HTTP date;
- use the server-provided delay directly, without applying client backoff;
- fall back to exponential backoff when the header is absent or malformed;
- use the injected clock in tests when converting an HTTP date into a delay;
- include the parsed
Retry-After value in debug logging and final retry-exhaustion context.
Retry-After does not override the operation's maximum attempts or elapsed-time budget. If the server asks the client to wait longer than the remaining budget, the client must stop and raise a typed rate-limit/retry-exhaustion error rather than retry early or sleep past the configured budget. The error should preserve the 429 status and original Retry-After header so the caller can decide whether to retry at a higher level.
NONE should not replay ambiguous request failures. In particular, it should not inherit the current behavior where all POST requests are retried after request exceptions.
LOG_INGESTION performs no generic transport retries; at the transport level it is equivalent to NONE. The variant exists so ingestion requests are explicitly labeled, letting the transport and tests assert that no generic retry loop stacks underneath the background logger's own at-least-once retry loop.
Structured errors
Replace body-only AugmentedHTTPError handling with an error that preserves useful context:
class BraintrustHTTPError(BraintrustAPIError, AugmentedHTTPError):
method: str
url: str
status_code: int
response_body: str
request_id: str | None
request_id_header: str | None
response_headers: Mapping[str, str]
attempts: int
retryable: bool
Braintrust response bodies should be preserved verbatim in structured errors. request_id should prefer x-bt-internal-trace-id and may fall back to recognized deployment headers such as x-vercel-id; the selected header name and an explicitly allowlisted subset of response headers should also be retained. Avoid denylist-based response filtering. The original transport exception should remain available through exception chaining.
BraintrustHTTPError must subclass the existing AugmentedHTTPError (braintrust.util) for the duration of the migration. AugmentedHTTPError is raised by response_raise_for_status() across logger.py, sandbox.py, functions/invoke.py, and the CLI, is caught internally (for example, in Experiment.summarize()), and is importable by user code; if migrated services raised an unrelated type, existing except AugmentedHTTPError: handlers would silently stop catching and handled fallbacks would become uncaught exceptions. Dropping the subclassing relationship is a future major-version decision.
Separate errors should cover transport failure, retry exhaustion, and response decoding. A retry-exhaustion error caused by a final HTTP response should remain a BraintrustHTTPError subtype so status and AugmentedHTTPError compatibility are preserved; exhaustion caused only by request exceptions should remain chained from the final transport exception. Service methods may translate specific statuses into domain outcomes, such as a documented 404 returning None.
Client facade and service layer
One transport, routed destinations
The current SDK has three HTTPConnection objects because requests may be sent to three destinations:
- app/control plane;
- API/data plane;
- function proxy.
Those destinations should be routing configuration, not separate transport instances. A single Transport and underlying requests.Session can send requests to multiple hosts; requests maintains the appropriate connection pools internally.
class RequestTarget(enum.Enum):
APP = "app"
API = "api"
PROXY = "proxy"
@dataclasses.dataclass
class EndpointRouter:
app_url: str
api_url: str | None = None
proxy_url: str | None = None
def resolve(self, target: RequestTarget, path: str) -> str:
...
@dataclasses.dataclass(frozen=True)
class ClientContext:
org_id: str
org_name: str
class BraintrustClient:
def __init__(
self,
*,
transport: Transport,
org_id: str,
org_name: str,
):
context = ClientContext(org_id=org_id, org_name=org_name)
self.projects = ProjectsAPI(transport, context)
self.experiments = ExperimentsAPI(transport, context)
self.datasets = DatasetsAPI(transport, context)
self.prompts = PromptsAPI(transport, context)
self.functions = FunctionsAPI(transport, context)
self.queries = QueriesAPI(transport, context)
self.attachments = AttachmentsAPI(transport, context)
EndpointRouter.resolve() must preserve the current proxy fallback: when proxy_url is unset, RequestTarget.PROXY resolves to the API URL. BraintrustState.proxy_conn() behaves this way today, and function invocation, sandbox listing, the devserver, and push all rely on it for organizations without a configured proxy URL. The router must also keep Universal Proxy URL normalization (stripping the /v1/proxy suffix), which _normalize_proxy_conn_url() already performs centrally inside proxy_conn() — the router inherits an existing centralized rule rather than fixing scattered call sites.
The constructor above is the low-level form, taking an already-bootstrapped transport. The public convenience constructor (BraintrustClient(api_key=..., org_name=...), shown in the follow-up section) performs login, organization selection, and URL discovery internally and then delegates to this form.
Each service identifies the target as part of its endpoint definition:
return self._transport.request_json(
RequestTarget.API,
"GET",
"/experiment-comparison2",
params=params,
retry_mode=RetryMode.SAFE_READ,
)
This gives us one timeout implementation, one retry engine, one error model, one debug-logging path, and one custom session/adapter injection point.
Can the destinations also become one URL?
For organizations using the Universal API, many control-plane and proxy routes are available from the API host. After login, the router should use the Universal API URL for every endpoint that the backend contract supports there. The login response already includes is_universal_api, api_url, and proxy_url; the Python SDK currently ignores is_universal_api.
The initial /api/apikey/login request still needs the configured app URL because URL discovery has not happened yet. Legacy and non-universal organizations may also require distinct app, API, or proxy hosts. Therefore, the client can guarantee one transport immediately, but cannot yet guarantee one physical origin for every deployment.
The router provides a migration path:
- bootstrap through
app_url;
- record
is_universal_api and the discovered URLs so the router can later prefer one Universal API origin — actually switching traffic to it is a user-visible routing change (firewall allowlists, self-hosted proxies, and cassette matching all observe the host) and ships in its own PR with release notes, never inside a "behavior change: none" PR;
- retain explicit per-target URLs for legacy and self-hosted compatibility. Preserve the current override precedence:
BRAINTRUST_API_URL/BRAINTRUST_PROXY_URL environment variables win over discovered organization URLs (_check_org_info). Note that no constructor override for api_url/proxy_url exists today — login() accepts only app_url, api_key, and org_name — so if the new client adds explicit URL parameters, their precedence (constructor over environment over discovered) is new behavior to define, not existing behavior to preserve;
- keep Universal Proxy URL normalization centralized, as
_normalize_proxy_conn_url() already does inside proxy_conn() today;
- preserve the proxy-to-API fallback when no proxy URL is configured;
- keep absolute unauthenticated URLs out of the router and
Transport.request() entirely; they are served by the specialized object-storage path, which never attaches the Braintrust Authorization header.
Bootstrap should:
- construct one router with the configured app URL;
- construct one session and transport;
- call
/api/apikey/login through the app target;
- select the requested organization;
- update the router with
api_url, proxy_url, and is_universal_api, then apply environment URL overrides with the same precedence as the current login path (and any new explicit constructor URLs ahead of both, per the migration-path note above);
- construct the service facade around the same transport;
- while the legacy connections exist, propagate the login result to them — set their tokens and call
make_long_lived() on the app, API, and proxy connections exactly as login() does today — so un-migrated call sites keep working and keep their post-login request-exception retries until PR 12 removes the legacy path.
BraintrustState can then hold the client. Existing app_conn(), api_conn(), and proxy_conn() methods — and the module-level api_conn()/app_conn()/proxy_conn() functions in logger.py, which framework2.py and cli/install/api.py import directly — remain as temporary isolated legacy connections. Each call site switches to the shared transport when it migrates to a service; the legacy sessions disappear only after their mutable surface is no longer needed.
The shim contract must cover HTTPConnection's mutable surface, not just its request methods: make_long_lived() (which installs RetryRequestExceptionsAdapter only when no custom adapter is set), set_token(), _set_adapter(), and direct .session access. During migration, these mutators must keep applying to isolated legacy connections only, so legacy call sites neither lose their post-login request-exception retries before PRs 6 and 8 assign explicit policies, nor stack the legacy retry loop underneath the new policy engine for migrated endpoints. New services use the policy-aware transport directly; a shim with raw .session access cannot honestly be backed by the same single session while preserving arbitrary mutation behavior.
Service responsibilities
A service owns endpoint semantics:
- request and response models, with forward-compatible response parsing that tolerates additive backend fields;
- validation before sending;
- endpoint path and method;
- target, organization scoping, and required endpoint headers such as
x-bt-org-name;
- retry mode;
- response parsing;
- documented status-to-domain translations.
For example, ExperimentsAPI would own registration, lookup, base lookup, and comparison. The comparison operation would use SAFE_READ and either return a valid comparison or raise a typed API error. It would never manufacture empty score maps.
The high-level Experiment.summarize() method would remain responsible for building ExperimentSummary and deciding whether a final comparison failure should raise or produce a clearly marked lenient result.
Specialized clients
Log ingestion
Background logging should use shared low-level request and error primitives but retain a dedicated ingestion implementation for batching, queueing, overflow uploads, at-least-once retries, synchronous flush behavior, and failed-payload persistence.
Function invocation
Function invocation needs a dedicated non-retrying path because it supports streaming, can be long-running, incurs provider cost, and can execute tools or arbitrary user code.
Object storage
Signed object-storage requests should not inherit Braintrust API retry behavior or Braintrust authorization headers. They execute outside Transport.request() and RequestTarget: the object-storage helper owns its own session (or per-request connection), separate from the shared Braintrust session, mirroring today's dedicated token-less HTTPConnection(base_url="") instances. Two properties of the current path must carry over explicitly:
- No Authorization header, ever. Session separation makes the credential-scoping guarantee structural rather than dependent on a per-request suppression branch.
- Adapter/session injection still applies. Today the object-store connections are constructed with the global
_http_adapter, so set_http_adapter() (and VCR-style test injection) reaches object-store uploads and downloads. The specialized path must keep an injection point, and PR 12's final target-independent set_http_adapter() contract must state explicitly that it includes object-store requests.
Each workflow must specify whether replay is safe:
- code-bundle upload to the same overwrite-enabled URL can retry;
- attachment upload PUTs use backend-supplied headers, including
If-None-Match: *, plus x-ms-blob-type: BlockBlob for Azure. The signed URL is issued with overwrite disabled. After an ambiguous upload result, replay may return an object-store precondition failure; the attachment client must check GET /attachment and translate the result rather than treating the upload as an overwrite or expecting initialization to issue another URL.
Compatibility strategy
- Keep existing high-level public APIs unchanged while migrating them to
braintrust.api.
- Preserve lazy login and lazy resource registration behavior.
- Preserve
set_http_adapter() opt-out semantics: supplying a custom adapter disables the SDK retry loop rather than composing two potentially retrying layers. An explicitly injected session also owns retries by default; callers may opt the SDK loop back in only when its active adapters do not retry. During migration, retain the characterized timing-dependent legacy scope. After unsafe proxy calls use RetryMode.NONE, normalize the final scope so the custom adapter consistently applies to app, API, proxy, and signed-object requests regardless of installation timing. The adapter owner is responsible for replay safety. The new public client should prefer explicit Session or Transport injection while retaining set_http_adapter() as the global convenience API.
- Keep
HTTPConnection as a compatibility wrapper or alias during migration, honoring the mutator contract (make_long_lived(), set_token(), _set_adapter(), raw .session access) described in the bootstrap section.
- Keep
BraintrustHTTPError a subclass of AugmentedHTTPError so existing except AugmentedHTTPError: handlers and user imports keep working (see "Structured errors").
- Re-export moved summary/domain types from their existing locations if needed.
- Do not change serialized result shapes in a transport-only PR.
- Make fallback behavior changes in dedicated PRs with release notes.
Incremental PR plan
Each PR should be independently mergeable and should avoid mixing code movement with behavior changes unless explicitly stated.
Planning PR: document the architecture and audit
Scope:
Behavior change: none.
PR 1: Extract the existing transport without changing behavior (merged in 625a5e77bcd0192bae4aa8e30c86e753d34187d2)
Scope:
- create
braintrust/api/_transport.py;
- move
HTTPConnection and RetryRequestExceptionsAdapter out of logger.py;
- preserve existing imports through aliases or re-exports;
- preserve current session, adapter, timeout, and retry behavior exactly, including the adapter's known full-body buffering of
stream=True responses (see "Session sharing constraints"; changed only in PR 9);
- move focused HTTP tests next to the transport or update their imports.
Behavior change: none.
Validation:
- existing core tests;
- characterization tests proving the extracted transport has identical behavior;
- import compatibility tests for any currently reachable symbols.
PR 2: Add structured errors and the policy-aware request engine (merged in 0cbf393e8992c756e3d10e16be4be9eb659c9abe)
Scope:
- add
errors.py and policies.py;
- implement one request loop that handles both transport exceptions and HTTP statuses;
- add
RetryMode.NONE, SAFE_READ, IDEMPOTENT_WRITE, and LOG_INGESTION;
- add
Retry-After, exponential backoff, elapsed-time limits, and deterministic sleep injection;
- keep existing call sites on a temporary legacy path so endpoint behavior does not change yet;
- ensure custom adapters do not cause accidental nested retries.
Behavior change: none for existing endpoint calls.
Validation:
- exercise the transport over real HTTP sockets rather than mocking
requests.Session, send(), or Response objects;
- use a deterministic local HTTP server only for protocol conditions that cannot be reliably produced by the real Braintrust service, such as scripted 429/5xx sequences, disconnects, timeouts, and retry-budget exhaustion;
- add 429 coverage for delay-seconds, HTTP-date, absent and malformed
Retry-After, and a delay larger than the remaining retry budget;
- prove valid
Retry-After values are used directly without client backoff;
- prove unsafe requests are sent once under
NONE;
- prove logical POST reads retry under
SAFE_READ;
- use recorded real Braintrust requests for authentication, headers, URL construction, and representative successful service calls.
PR 3: Add client bootstrap and service facade
Scope:
- add the synchronous
BraintrustClient, AuthAPI, EndpointRouter, and RequestTarget, without thread-wrapped async resource methods;
- centralize app login, organization selection, and API/proxy URL discovery;
- use one transport and session across the new client's routed app, API, and proxy requests, keeping each request's destination host identical to today's (including the proxy-to-API fallback when no proxy URL is configured);
- record
is_universal_api and the discovered URLs in the router without changing which host any request targets; switching traffic to the Universal API origin is a separate PR with release notes;
- let
BraintrustState lazily own a client, with thread-safe lazy construction;
- retain the
BraintrustState connection methods and the module-level api_conn()/app_conn()/proxy_conn() functions as isolated legacy connections, honoring the mutator contract from the bootstrap section rather than pretending raw .session mutation can safely share the new client's session;
- keep the centralized login hydrating those legacy connections — set their tokens and call
make_long_lived() exactly as login() does today (bootstrap step 7) — so un-migrated call sites neither lose authentication nor their post-login request-exception retries;
- support custom sessions/adapters for VCR and user configuration, preserving the
set_http_adapter() no-stacked-retries invariant while the legacy path remains.
Behavior change: none.
Validation:
- existing login and state tests;
- VCR-backed login and organization-discovery tests recorded from the real Braintrust service;
- multiple-organization selection tests using recorded real response shapes;
- custom adapter/session integration tests without mocked response objects;
- Universal API routing, legacy multi-origin routing, and explicit/environment URL override precedence tests;
- proxy-target resolution falls back to the API host when no proxy URL is configured;
- legacy mutator coverage:
make_long_lived(), set_token(), and set_http_adapter() retain their characterized target- and timing-dependent behavior;
- after a login through the new bootstrap, the legacy connections are authenticated and long-lived: an un-migrated call site can issue a request and still gets request-exception retries;
- no duplicate client/session creation under concurrent lazy access.
PR 4: Add the experiment service with behavior parity
Scope:
- add experiment request/response models;
- migrate experiment lookup, explicit comparison-name lookup, base lookup, and comparison requests into
ExperimentsAPI;
- delegate
Experiment methods to the service;
- preserve the current high-level fallback behavior temporarily;
- preserve the documented status-to-domain translations: base-experiment lookup returns
None on HTTP 400 ("no base experiment") instead of raising a typed error;
- mark reads as
SAFE_READ so transient 429/5xx failures receive bounded retries.
Behavior change:
- transient read failures may now recover;
- final summary failure behavior remains unchanged until the next PR.
Validation:
- VCR-backed experiment creation, base lookup, and comparison against the real Braintrust service;
- real backend response shapes with and without a baseline and with an explicit baseline;
- a fresh project's base-experiment lookup (HTTP 400) returns
None without raising or retrying;
- end-to-end
Experiment.summarize() coverage rather than mocking ExperimentsAPI;
- real-socket transport coverage for transient 429/5xx and network failures that cannot be recorded reliably from Braintrust;
- no retry for 400/401/403 or decoding failures;
- existing experiment summary formatting and serialization tests.
PR 5: Fix experiment summary failure semantics
Scope:
- stop treating failed comparison retrieval as a successful empty comparison;
- stop silently swallowing failures of the explicit comparison-name lookup; surface them in the structured failure description;
- add a nested discriminated comparison result to
ExperimentSummary as the authoritative representation: SummarySuccess, SummarySkipped, or SummaryFailed;
- put the real
scores and metrics maps only on SummarySuccess, a reason on SummarySkipped, and structured error details only on SummaryFailed; preserve Braintrust response bodies verbatim and include only explicitly allowlisted response headers;
- keep the existing top-level
ExperimentSummary.scores and ExperimentSummary.metrics as deprecated read-only bridges delegating to the nested result (the success maps on SummarySuccess, empty maps otherwise) and keep serializing them alongside the nested field — ExperimentSummary is a public serialized dataclass whose fields user code, custom reporters, and CI tooling read directly, so removing them outright would break Goal 6; removal is a future major-version change;
- add strict behavior that raises the typed API error for automation;
- update CLI and JSON reporters to display or serialize "summary unavailable" explicitly;
- document the compatibility and default-mode decision.
Decision:
Experiment.summarize() is strict by default and raises the typed API error when comparison retrieval fails;
- framework-driven evals are also strict by default: a summary failure fails the eval rather than reporting successful completion;
- callers may explicitly opt into lenient reporting, which returns experiment metadata and a failure-marked summary instead of manufacturing empty score or metric maps.
Behavior change: intentional and user-visible.
Validation:
- genuine empty scores produce
SummarySuccess(scores={}, metrics=...);
- the deprecated top-level
scores/metrics bridges mirror the SummarySuccess maps, are empty for skipped/failed summaries, and stay in the serialized output;
summarize_scores=False produces SummarySkipped with a reason;
- retry exhaustion raises in strict mode and produces
SummaryFailed only in explicitly lenient mode, never an empty success;
- strict mode raises;
- explicitly lenient eval completion still returns experiment metadata and URL;
- JSON and human-readable output expose the failure.
PR 6: Migrate safe read services
Split this into small PRs if review size warrants it.
Suggested order:
QueriesAPI: BTQL and prompt version queries.
DatasetsAPI: lookup, environment resolution, fetch, and summary.
PromptsAPI: prompt retrieval.
FunctionsAPI: parameter/function metadata retrieval only.
ProjectsAPI: project lookup and read-only metadata.
AttachmentsAPI: metadata and download reads.
Behavior change: transient safe-read failures receive consistent retries.
Validation:
- endpoint-specific response parsing;
- pagination/cursor behavior;
- pinned and environment lookups;
- 4xx errors are not retried;
- fallback layers receive typed underlying errors.
PR 7: Make prompt and parameter fallback explicit
Scope:
- keep cache fallback in the high-level prompt and parameter loaders, not in the API services;
- define a public
LoadedResource[T] envelope containing value, source (api, memory_cache, or disk_cache), cached_at, and an optional structured fallback_error;
- do not change existing loader return types:
load_prompt() keeps returning Prompt (Goal 6), and the load metadata is attached to the returned object as a load_info attribute carrying the same fields; only new braintrust.api surfaces return the LoadedResource[T] envelope directly;
- stop rewriting infrastructure failures as "not found" for pinned versions or environments;
- preserve the original typed exception as the cause;
- do not expose a misleading
stale boolean: for unpinned data the client cannot know whether a cached value is outdated, so expose only source, cache time, and the API failure that caused fallback.
Behavior change: clearer errors and explicit cache-source metadata.
Validation:
- latest unpinned request can use cache after a transient failure and returns source, cache time, and structured fallback-error metadata;
- pinned/environment requests surface infrastructure failures;
- genuine 404/not-found remains distinguishable;
- cache corruption and cache miss preserve the server cause.
PR 8: Migrate registration and deployment writes
Use separate service-level PRs for different retry safety classes.
Scope:
- plain project get-or-create registration: explicitly
IDEMPOTENT_WRITE; variants that create secondary resources require their own classification;
- named dataset registration with
update=False (including /v1/dataset POST): explicitly IDEMPOTENT_WRITE; updates, including /v1/dataset PUT, require an audit-effect review before opting in;
- named experiment registration with
update=True (the /v1/experiment default when ensure_new is not true): conditionally IDEMPOTENT_WRITE; unnamed registration, ensure_new=True, and all other update=False registration use NONE;
- code-bundle allocation:
NONE;
- insert-functions:
NONE until idempotency exists;
- sandbox creation/listing:
NONE;
- organization patching: explicit policy rather than inherited behavior.
Behavior change:
- unsafe POST operations no longer receive implicit request-exception retries;
- idempotent registration operations can recover from transient statuses.
Validation:
- a real-socket ambiguous/lost response test does not create a second experiment or code bundle;
- VCR-backed project and dataset get-or-create operations recover safely;
- function replacement and sandbox operations are attempted once;
- errors retain enough context for reconciliation.
PR 9: Migrate function invocation
Scope:
- add a dedicated invocation client;
- preserve streaming and non-streaming behavior;
- use
RetryMode.NONE by default;
- separate connection timeout from potentially long response/read timeout;
- preserve provider and function errors without generic retries.
Behavior change:
- eliminates implicit duplicate invocation after request exceptions;
- streamed responses are no longer fully buffered by the transport (today
RetryRequestExceptionsAdapter downloads the entire body even for stream=True, so streamed invocations are buffered in memory); this changes memory and latency behavior for streaming invocations and must be called out in release notes.
Validation:
- streaming is not eagerly consumed by the transport;
- read timeout does not replay the invocation;
- non-streaming response parsing remains compatible;
- 429/5xx responses surface immediately unless a future explicit invocation policy is introduced.
PR 10: Migrate attachment workflows
Scope:
- centralize attachment initialization, upload, status, metadata, and download;
- keep initialization on
RetryMode.NONE: the client-generated key prevents duplicate attachment identity, but the backend creates the status object with overwrite disabled and returns 409 on duplicate initialization instead of a fresh signed URL;
- send the backend-provided
If-None-Match: * upload header and add x-ms-blob-type: BlockBlob for Azure;
- retry safe metadata reads and explicitly idempotent status updates;
- reconcile an ambiguous upload by checking
GET /attachment for contentLength/download metadata and then writing done if the persisted status still says uploading. Interpret object-store precondition failures only after that check;
Behavior change:
- attachment failures are more accurately reported; recoverability remains bounded by the current create-only initialization contract;
- initialization loses today's implicit request-exception retries (attachment upload runs after
login() on the long-lived API connection, whose adapter retries connection failures up to 10 times), so a plain connection failure during POST /attachment — where the request never reached the backend and replay would be safe — now surfaces immediately. This must be called out in release notes; a future refinement could retry only failures known to have occurred before the request was sent, without waiting for the backend initialize/resume contract.
Validation:
- initialization request fails before the backend creates the status object;
- initialization succeeds but its response is lost, and the client does not blindly turn that into a misleading successful retry;
- upload succeeds followed by a lost response and is reconciled through
GET /attachment;
- duplicate initialization returns and preserves HTTP 409 under the current backend contract;
- status update retries;
- object download retries;
If-None-Match and Azure header behavior across supported object stores.
PR 11: Isolate background log ingestion
Scope:
- move background log HTTP operations into a dedicated ingestion module;
- make its at-least-once semantics explicit;
- prevent nested transport retries;
- preserve overflow uploads, batching, synchronous flush, and payload persistence;
- allow the memoized
/version probe failure to be re-probed instead of permanently disabling overflow uploads for the process;
- separately decide whether 413 and async retry exhaustion should continue dropping data.
Behavior change: ideally none initially; data-loss semantics should be changed only in a dedicated follow-up.
Validation:
- exact attempt counts;
- no adapter-level multiplication of attempts;
- successful replay of stable log payloads;
- overflow upload behavior;
- synchronous versus asynchronous failure behavior;
- failed payload persistence.
PR 12: Remove the legacy transport path
Scope:
- remove temporary legacy retry behavior;
- stop constructing endpoint-specific
HTTPConnection objects in logger.py and consolidate Braintrust targets onto the policy-aware session only after all unsafe proxy calls have explicit NONE policies;
- remove compatibility shims that are not publicly supported;
- retain documented adapter/session injection and implement the decided target-independent
set_http_adapter() scope, including the intentional removal of the old late-mutation quirk and disabling SDK retries whenever a custom adapter is present;
- update internal imports and architecture documentation.
Behavior change: completes adoption of explicit endpoint policies.
Validation:
- full core test suite;
- lint and type checks;
- wheel sanity check if imports or package discovery changed;
- a search confirming that Braintrust endpoint paths live in service modules or explicitly specialized clients.
Follow-up: stabilize and document the public API
The client is exposed through braintrust.api as services are added:
from braintrust.api import BraintrustClient
client = BraintrustClient(api_key="...", org_name="my-org")
project = client.projects.create(name="my-project")
summary = client.experiments.summarize(experiment_id="...")
This is the convenience constructor: it performs login, organization selection, and URL discovery internally, then builds the transport-based client described in the facade section.
After the migrated services have seen internal and external use, perform a dedicated API review covering naming, pagination, model stability, whether a separate native AsyncBraintrustClient is warranted, and compatibility guarantees. Add public reference documentation and examples for the supported service surface.
Testing strategy
Prefer the highest-fidelity practical test for each behavior:
- Use end-to-end tests through the existing public SDK API whenever possible. Do not replace
BraintrustClient, resource services, or transports with mocks in high-level tests.
- Use VCR-backed requests recorded from the real Braintrust service for endpoint contracts, authentication, URL routing, request bodies, response parsing, and fallback behavior. Extend existing cassettes rather than constructing canned response dictionaries.
- Use the local Braintrust backend for integration scenarios that require controlled server state when practical.
- Use a deterministic local HTTP server over real sockets only for transport failures that cannot be safely or reliably induced against Braintrust, including disconnects, timeouts, malformed responses, exact 429
Retry-After timing, and scripted 5xx recovery. Prefer this over mocking requests.Session.send() or creating fake Response objects.
- Use ordinary unit tests only for pure parsing, policy selection, delay calculation, and model validation.
Additional rules:
- Add characterization coverage before moving each existing call site.
- Test attempt counts, not only final success or failure.
- For every write operation, include realistic coverage showing whether replay is allowed.
- Do not hand-author VCR cassettes; record them from actual requests.
- Test custom adapter/session injection through real requests to prevent double retries.
- Keep log-ingestion tests separate from ordinary API transport tests.
- If a mock or fake is unavoidable, document why real HTTP, VCR, or the local backend was impractical.
Resolved decisions
Experiment.summarize() and framework-driven evals are strict by default. A summary retrieval failure raises the typed API error and fails the eval. Explicit lenient mode returns experiment metadata plus a failure-marked summary; the authoritative nested result never disguises failure as empty score or metric maps (only the deprecated top-level bridge fields stay empty, as they are today).
ExperimentSummary contains a nested discriminated comparison result as the authoritative representation: SummarySuccess has score and metric maps, SummarySkipped has a reason, and SummaryFailed has structured error details. Braintrust response bodies are preserved verbatim and response headers use an explicit allowlist. Strict mode raises rather than returning SummaryFailed; that variant exists for explicit lenient mode. The nested result never encodes state through nullable or fake empty maps. The existing top-level scores/metrics fields remain as deprecated read-only bridges (delegating to SummarySuccess, empty otherwise) and stay in the serialized shape until a future major version, so existing consumers of the public dataclass keep working.
- The default retryable 5xx statuses are 500, 502, 503, and 504, and only replayable operations may retry them. A 503
Retry-After is honored under the same rules as 429. Other 5xx statuses require an explicit endpoint override.
- The default
SAFE_READ policy permits four total attempts within 60 seconds of total monotonic elapsed time, including requests and sleeps, with a 20-second default per-attempt timeout so a first-attempt timeout still leaves room to retry. Timeouts and sleeps are clamped to the remaining budget. NONE operations keep the current 60-second default timeout. Long-running endpoints require explicit policy overrides covering both per-attempt timeout and budget, and a policy whose budget does not exceed its per-attempt timeout is rejected.
- Custom transports do not compose with SDK retries by default: supplying a session or adapter disables the SDK retry loop, and the transport owner assumes responsibility for replay safety. A caller may explicitly enable SDK retries only when it knows the injected adapters do not retry. The final adapter scope consistently includes app, API, proxy, and signed-object requests regardless of installation timing. Explicit
Session or Transport injection is preferred for the new client, while set_http_adapter() remains the global convenience API.
- Server idempotency design is out of scope for this client architecture. The client classifies writes against the current documented backend contract and keeps unsafe operations on
RetryMode.NONE; it may opt into retries later if the backend independently provides a verified idempotency contract.
- Cache-source metadata is modeled as a public
LoadedResource[T] with the value, source (api, memory_cache, or disk_cache), cache timestamp, and optional structured fallback error. Braintrust response bodies are preserved verbatim and response headers use an explicit allowlist. Existing high-level loaders keep their return types — load_prompt() still returns Prompt, with the metadata attached as a load_info attribute — and only new braintrust.api surfaces return the envelope directly. API service methods remain cache-free. No stale boolean is exposed because the client cannot establish whether unpinned cached data is current.
- Preserve the existing background workers for queued log ingestion, but keep the initial public resource client synchronous. Do not expose
asyncio.to_thread() wrappers as async client methods. A future async API, if warranted, should be a separate native AsyncBraintrustClient with dedicated transport, cancellation, streaming, timeout, and test coverage.
Recommended first implementation slice
The smallest useful sequence is:
- extract the transport without behavior changes;
- add typed errors and opt-in retry policies;
- add the client facade;
- migrate experiment base lookup and comparison reads;
- fix experiment summary failure semantics.
That sequence addresses issue #639 while establishing the architecture needed to safely migrate the remaining endpoints.
Status: Implementation in progress
Implementation progress
625a5e77bcd0192bae4aa8e30c86e753d34187d2(ref(api): establish transport boundary for API client).0cbf393e8992c756e3d10e16be4be9eb659c9abe(feat(api): add policy-aware HTTP transport, #653).Context
The Python SDK does not currently have an API module comparable to the Go SDK's
api/package. HTTP transport, authentication, endpoint calls, response parsing, retries, and product-level fallback behavior are spread acrosslogger.py,functions/invoke.py,framework2.py,sandbox.py, the devserver, and CLI modules.This became especially visible in issue #639:
Experiment.summarize()catches any failure fromexperiment-comparison2and returns empty score and metric maps. That makes a failed read look like a successful experiment with no scores.An audit of the Python SDK's Braintrust-directed HTTP calls found a broader transport problem:
RetryRequestExceptionsAdapterretries request exceptions for every HTTP method, including unsafe POST requests — unless the user installed a custom adapter viaset_http_adapter(), in which casemake_long_lived()is a no-op and SDK-managed retries are replaced entirely.The SDK should centralize endpoint definitions and make retry and fallback behavior explicit per operation.
Goals
braintrust.init(),Experiment.summarize(), andload_prompt()compatible while migrating them internally: signatures, return types, and serialized shapes stay backward compatible (using deprecated bridge fields where a model gains a new authoritative representation). Intentional behavior changes, such as strict-by-default summary failures, ship only in dedicated PRs with release notes.set_http_adapter().braintrust.api.Non-goals
braintrust.api.Current architecture
BraintrustStateowns threeHTTPConnectioninstances:HTTPConnectionandRetryRequestExceptionsAdapterare defined inlogger.py. Endpoint calls are distributed across the package.The Go SDK provides a useful organizational model:
Its main strengths are the resource-oriented service layer and typed HTTP errors. The Python implementation should adopt those ideas, but add operation-level retry policies rather than copying the Go transport directly.
HTTP call-site audit summary
Safe logical reads
These operations can retry transient network failures, 408, 429, and transient 5xx responses:
pingand API version discovery;Some of these are POST requests even though they are reads, so an HTTP-method-only policy is insufficient.
Some reads also translate specific statuses into domain results today, and the migration must preserve those translations explicitly. In particular, base-experiment lookup (
POST /api/base_experiment/get_idon the app connection) treats HTTP 400 as "no base experiment" and returnsNone. A fresh project's first eval always takes this path, so migrating the endpoint to raise typed errors for all 4xx responses would break it;ExperimentsAPImust keep the 400-to-Nonerule as a documented status-to-domain translation.Idempotent or conditionally idempotent writes
update=Falseresolves name conflicts to the existing dataset.update=Truereapplies the update but may duplicate audit side effects.update=Truereturns the existing experiment and is conditionally replay-safe. Registration without an explicit name, or withupdate=False, is not.These operations may opt into retries, but the policy should be explicit and should account for secondary effects such as audit records.
Attachment operations that require reconciliation
The current backend contract does not make the whole attachment workflow transparently replayable:
POST /attachmentfirst creates<attachment-key>.status.jsonwithallowOverwrite: false. Repeating the request for the same client-generated key returns HTTP 409 instead of another signed upload URL. A lost successful initialization response therefore cannot be recovered by blindly replaying the POST, so this operation cannot use generic status retries under the current contract.If-None-Match: *for the signed upload PUT, and the object-store URL is issued with overwrite disabled. The SDK also addsx-ms-blob-type: BlockBlobfor Azure. Replaying an upload after an ambiguous success is non-destructive, but it can return an object-store precondition failure rather than the original success.GET /attachmentchecks both the status object and uploaded object. A specialized attachment workflow can usecontentLength/download metadata to establish that the object exists after an ambiguous upload. If the original status object still saysuploading, that status currently takes precedence, so the client may need to writedone; the backend does not automatically reportdonemerely because the object exists. This is workflow-level reconciliation, not a reason to classify either initialization or upload as an ordinaryIDEMPOTENT_WRITE.Under the current contract, attachment initialization uses
RetryMode.NONE. Upload and status handling remain specialized and explicitly interpret precondition failures and metadata/status reads.Unsafe operations
The following should not be replayed automatically without an idempotency mechanism:
update=True, because the backend creates a new generated or uniquely suffixed experiment;Specialized at-least-once operations
Background
/logs3ingestion already has batching and its own retry loop. Each backend execution gets a new transaction ID, so retries are at-least-once rather than truly idempotent. Overflow upload setup can also leave unused objects after retries.Logging should retain a dedicated policy and should not accidentally gain a second generic retry loop.
Existing fallback behavior
Transport retries do not resolve ambiguous fallback semantics. Current examples include:
can_use_overflow: Falseand disables overflow uploads for the rest of the process without ever re-probing;Experiment.summarize()swallowing any exception and continuing without a comparison name;The API layer should report errors. Higher layers should own and clearly mark any fallback.
Backend contract assumptions
The architecture and endpoint policies must be checked against the backend contract, not inferred from HTTP verbs or endpoint names. The current backend establishes these details:
GETorPOST /api/apikey/loginreturnsorg_infoentries withid,name,api_url,proxy_url,realtime_url,is_universal_api, and Git metadata settings. The control plane currently fills a missingapi_urlfrom the deployment default and a missingproxy_urlfrom the resolved API URL, but the Python router should still tolerate null or omitted values for older and self-hosted deployments.GET/POSTroutes matching/api/:object_typeand/api/:object_type/:actionto its configured app origin. Bootstrap still has to contact the configured app origin because the organization-specific API URL is not known before login.Authorization: Bearer <token>. Proxy operations such as function invocation also require operation-specific headers such asx-bt-org-name; these belong in the service endpoint definition rather than in a global transport default.x-bt-internal-trace-idwhen tracing is active. Control-plane infrastructure may expose a deployment request ID such asx-vercel-id. Structured errors should capture the recognized response identifier and retain the relevant response headers rather than assuming a single universalrequest-idheader.Code/Message, while control-plane errors may useerror. Services must use status codes for control flow and treat the parsed body as diagnostic context, not depend on one error-body schema./v1/:object_typefacade maps writes onto legacy registration semantics. ProjectPOSTis get-or-create. DatasetPOSTsendsupdate=False, while datasetPUTsendsupdate=True. ExperimentPOST/PUTsendupdate=Trueunlessensure_new=True; a named experiment is therefore get-existing by default, but an unnamed experiment orensure_new=Truestill creates a new experiment. Retry policy must be selected from those effective semantics and payload fields, not fromPOSTversusPUT.These assumptions were checked against
app/pages/api/apikey/login.ts,app/pages/api/{project,dataset,experiment}/register.ts,app/pages/api/base_experiment/get_id.ts,api-ts/src/api.ts,api-ts/src/attachment.ts,api-ts/src/proxy/register-code.ts, andapp-schema/schema.sqlin the Braintrust backend repository. They should have contract tests against the local backend or VCR recordings. If the backend contract changes, update this section and the corresponding endpoint policy together.Proposed package structure
Use the public
braintrust.apinamespace from the beginning. Public client, service, model, error, and policy types live directly in this package; low-level implementation details use underscore-prefixed modules:braintrust.api.__init__should explicitly export the supported public surface._transport.pyremains private in the same way that the Go SDK keeps its HTTP implementation underinternal/https.Transport layer
The policy-driven client's end state should use one configured transport and one underlying
requests.Sessionfor Braintrust app, API, and proxy targets. The initial public resource client is synchronous; it does not expose thread-wrapped pseudo-async methods. Existing background workers remain responsible for queued log ingestion and continue running blocking HTTP away from the caller. An endpoint router supplies the destination URL for each request; separate Braintrust destinations do not require separate transport objects. During incremental migration, legacyHTTPConnectionsessions may need to coexist until their call sites and mutable adapter behavior have moved; forcing every compatibility shim onto the shared session in PR 3 would not be behavior-preserving.The transport is responsible only for HTTP mechanics:
requests.Sessionor adapter injection.Per-attempt timeout is configured only through
RetryPolicy, so timeout and elapsed-budget validation have one source of truth. A transport closes sessions it creates and supports context-manager cleanup; an injected session remains owned by its caller.It must not implement domain fallbacks such as returning cached prompts or empty experiment summaries.
Session sharing constraints
Consolidating three sessions into one introduces constraints the current architecture satisfies by construction:
HTTP_REQUEST_THREAD_POOL, sized tocpu_count()).requests.Sessionis not safe for concurrent mutation: session-level headers and the cookie jar must be treated as immutable after bootstrap. Authorization and token refresh must be applied per request (or per target) rather than by mutatingsession.headersthe wayset_token()does today, and lazy client construction must be guarded against concurrent initialization.requests.Sessionselects adapters by URL prefix, not by logicalRequestTarget. Legacy connections remain isolated until endpoint policies replace their timing-dependent mutation quirks. In the final client, a custom adapter applies consistently to app, API, proxy, and signed-object requests regardless of installation timing. Injecting a session or adapter disables the SDK retry loop by default; that transport owner assumes responsibility for replay safety. A caller may explicitly enable the SDK loop only when it knows the injected adapters do not retry.RetryRequestExceptionsAdapter.send()fully downloadsresponse.contentbefore returning, even forstream=Truerequests, so streamed proxy function invocations are fully buffered in memory today. The new transport must support true streaming; un-buffering invocation responses is a user-visible behavior change and is handled explicitly in PR 9.A conceptual interface is:
Retry mode must be supplied by the endpoint implementation rather than inferred only from the HTTP method. For example, BTQL is a safe logical read even though it uses POST, while function invocation is not retryable even though it also uses POST.
RequestTargetcovers Braintrust destinations only. Absolute signed object-storage URLs are deliberately not expressible throughTransport.request(); they go through the specialized object-storage path described under "Specialized clients", which guarantees the Braintrust Authorization header is never attached.Retry behavior
SAFE_READand explicitly approvedIDEMPOTENT_WRITEoperations should support:Retry-After;Ordinary 4xx responses, JSON decoding failures, schema validation failures, and deterministic request-construction failures such as invalid URLs, schemas, or headers should not retry by default. Only transient request exceptions such as connection failures and timeouts are eligible. The default retryable 5xx set is exactly 500, 502, 503, and 504; statuses such as 501, 505, 507, 508, and 511 are not retried unless an endpoint declares an explicit, evidence-based override adding them (overrides may also shrink the set). A retryable 503 with
Retry-Afterfollows the same server-delay and budget rules as 429.The SDK retry loop must also be able to reproduce the request body. JSON bodies and immutable
str/bytesdata may retry. File-like bodies, generators, multipartfiles, and other potentially consumed data are sent once unless a specialized workflow prepares and rewinds them explicitly.The default
SAFE_READpolicy allows four total attempts and 60 seconds of total elapsed time, including request execution and sleeps. Elapsed time uses a monotonic clock, and each request timeout and sleep is clamped to the remaining budget.The per-attempt timeout must be materially below the elapsed budget, or timeout retries are structurally impossible: today's default request timeout is 60 seconds (
BRAINTRUST_HTTP_TIMEOUT, applied inmake_long_lived()), and under a 60-second budget a first-attempt read timeout would exhaust the entire budget, so timeouts — first on the retryable list — would never actually retry. Therefore:SAFE_READper-attempt timeout is 20 seconds, so a first-attempt timeout leaves budget for at least two more attempts;NONEoperations keep the current 60-second default timeout; their behavior is unchanged because they never retry;HTTP 429 and
Retry-AfterA 429 response may only retry when the operation's retry mode permits replay.
NONEoperations, such as function invocation, must surface the 429 without replaying the request.For retryable operations, the client must respect
Retry-After:Retry-After: 5) and an HTTP date;Retry-Aftervalue in debug logging and final retry-exhaustion context.Retry-Afterdoes not override the operation's maximum attempts or elapsed-time budget. If the server asks the client to wait longer than the remaining budget, the client must stop and raise a typed rate-limit/retry-exhaustion error rather than retry early or sleep past the configured budget. The error should preserve the 429 status and originalRetry-Afterheader so the caller can decide whether to retry at a higher level.NONEshould not replay ambiguous request failures. In particular, it should not inherit the current behavior where all POST requests are retried after request exceptions.LOG_INGESTIONperforms no generic transport retries; at the transport level it is equivalent toNONE. The variant exists so ingestion requests are explicitly labeled, letting the transport and tests assert that no generic retry loop stacks underneath the background logger's own at-least-once retry loop.Structured errors
Replace body-only
AugmentedHTTPErrorhandling with an error that preserves useful context:Braintrust response bodies should be preserved verbatim in structured errors.
request_idshould preferx-bt-internal-trace-idand may fall back to recognized deployment headers such asx-vercel-id; the selected header name and an explicitly allowlisted subset of response headers should also be retained. Avoid denylist-based response filtering. The original transport exception should remain available through exception chaining.BraintrustHTTPErrormust subclass the existingAugmentedHTTPError(braintrust.util) for the duration of the migration.AugmentedHTTPErroris raised byresponse_raise_for_status()acrosslogger.py,sandbox.py,functions/invoke.py, and the CLI, is caught internally (for example, inExperiment.summarize()), and is importable by user code; if migrated services raised an unrelated type, existingexcept AugmentedHTTPError:handlers would silently stop catching and handled fallbacks would become uncaught exceptions. Dropping the subclassing relationship is a future major-version decision.Separate errors should cover transport failure, retry exhaustion, and response decoding. A retry-exhaustion error caused by a final HTTP response should remain a
BraintrustHTTPErrorsubtype so status andAugmentedHTTPErrorcompatibility are preserved; exhaustion caused only by request exceptions should remain chained from the final transport exception. Service methods may translate specific statuses into domain outcomes, such as a documented 404 returningNone.Client facade and service layer
One transport, routed destinations
The current SDK has three
HTTPConnectionobjects because requests may be sent to three destinations:Those destinations should be routing configuration, not separate transport instances. A single
Transportand underlyingrequests.Sessioncan send requests to multiple hosts;requestsmaintains the appropriate connection pools internally.EndpointRouter.resolve()must preserve the current proxy fallback: whenproxy_urlis unset,RequestTarget.PROXYresolves to the API URL.BraintrustState.proxy_conn()behaves this way today, and function invocation, sandbox listing, the devserver, andpushall rely on it for organizations without a configured proxy URL. The router must also keep Universal Proxy URL normalization (stripping the/v1/proxysuffix), which_normalize_proxy_conn_url()already performs centrally insideproxy_conn()— the router inherits an existing centralized rule rather than fixing scattered call sites.The constructor above is the low-level form, taking an already-bootstrapped transport. The public convenience constructor (
BraintrustClient(api_key=..., org_name=...), shown in the follow-up section) performs login, organization selection, and URL discovery internally and then delegates to this form.Each service identifies the target as part of its endpoint definition:
This gives us one timeout implementation, one retry engine, one error model, one debug-logging path, and one custom session/adapter injection point.
Can the destinations also become one URL?
For organizations using the Universal API, many control-plane and proxy routes are available from the API host. After login, the router should use the Universal API URL for every endpoint that the backend contract supports there. The login response already includes
is_universal_api,api_url, andproxy_url; the Python SDK currently ignoresis_universal_api.The initial
/api/apikey/loginrequest still needs the configured app URL because URL discovery has not happened yet. Legacy and non-universal organizations may also require distinct app, API, or proxy hosts. Therefore, the client can guarantee one transport immediately, but cannot yet guarantee one physical origin for every deployment.The router provides a migration path:
app_url;is_universal_apiand the discovered URLs so the router can later prefer one Universal API origin — actually switching traffic to it is a user-visible routing change (firewall allowlists, self-hosted proxies, and cassette matching all observe the host) and ships in its own PR with release notes, never inside a "behavior change: none" PR;BRAINTRUST_API_URL/BRAINTRUST_PROXY_URLenvironment variables win over discovered organization URLs (_check_org_info). Note that no constructor override forapi_url/proxy_urlexists today —login()accepts onlyapp_url,api_key, andorg_name— so if the new client adds explicit URL parameters, their precedence (constructor over environment over discovered) is new behavior to define, not existing behavior to preserve;_normalize_proxy_conn_url()already does insideproxy_conn()today;Transport.request()entirely; they are served by the specialized object-storage path, which never attaches the Braintrust Authorization header.Bootstrap should:
/api/apikey/loginthrough the app target;api_url,proxy_url, andis_universal_api, then apply environment URL overrides with the same precedence as the current login path (and any new explicit constructor URLs ahead of both, per the migration-path note above);make_long_lived()on the app, API, and proxy connections exactly aslogin()does today — so un-migrated call sites keep working and keep their post-login request-exception retries until PR 12 removes the legacy path.BraintrustStatecan then hold the client. Existingapp_conn(),api_conn(), andproxy_conn()methods — and the module-levelapi_conn()/app_conn()/proxy_conn()functions inlogger.py, whichframework2.pyandcli/install/api.pyimport directly — remain as temporary isolated legacy connections. Each call site switches to the shared transport when it migrates to a service; the legacy sessions disappear only after their mutable surface is no longer needed.The shim contract must cover
HTTPConnection's mutable surface, not just its request methods:make_long_lived()(which installsRetryRequestExceptionsAdapteronly when no custom adapter is set),set_token(),_set_adapter(), and direct.sessionaccess. During migration, these mutators must keep applying to isolated legacy connections only, so legacy call sites neither lose their post-login request-exception retries before PRs 6 and 8 assign explicit policies, nor stack the legacy retry loop underneath the new policy engine for migrated endpoints. New services use the policy-aware transport directly; a shim with raw.sessionaccess cannot honestly be backed by the same single session while preserving arbitrary mutation behavior.Service responsibilities
A service owns endpoint semantics:
x-bt-org-name;For example,
ExperimentsAPIwould own registration, lookup, base lookup, and comparison. The comparison operation would useSAFE_READand either return a valid comparison or raise a typed API error. It would never manufacture empty score maps.The high-level
Experiment.summarize()method would remain responsible for buildingExperimentSummaryand deciding whether a final comparison failure should raise or produce a clearly marked lenient result.Specialized clients
Log ingestion
Background logging should use shared low-level request and error primitives but retain a dedicated ingestion implementation for batching, queueing, overflow uploads, at-least-once retries, synchronous flush behavior, and failed-payload persistence.
Function invocation
Function invocation needs a dedicated non-retrying path because it supports streaming, can be long-running, incurs provider cost, and can execute tools or arbitrary user code.
Object storage
Signed object-storage requests should not inherit Braintrust API retry behavior or Braintrust authorization headers. They execute outside
Transport.request()andRequestTarget: the object-storage helper owns its own session (or per-request connection), separate from the shared Braintrust session, mirroring today's dedicated token-lessHTTPConnection(base_url="")instances. Two properties of the current path must carry over explicitly:_http_adapter, soset_http_adapter()(and VCR-style test injection) reaches object-store uploads and downloads. The specialized path must keep an injection point, and PR 12's final target-independentset_http_adapter()contract must state explicitly that it includes object-store requests.Each workflow must specify whether replay is safe:
If-None-Match: *, plusx-ms-blob-type: BlockBlobfor Azure. The signed URL is issued with overwrite disabled. After an ambiguous upload result, replay may return an object-store precondition failure; the attachment client must checkGET /attachmentand translate the result rather than treating the upload as an overwrite or expecting initialization to issue another URL.Compatibility strategy
braintrust.api.set_http_adapter()opt-out semantics: supplying a custom adapter disables the SDK retry loop rather than composing two potentially retrying layers. An explicitly injected session also owns retries by default; callers may opt the SDK loop back in only when its active adapters do not retry. During migration, retain the characterized timing-dependent legacy scope. After unsafe proxy calls useRetryMode.NONE, normalize the final scope so the custom adapter consistently applies to app, API, proxy, and signed-object requests regardless of installation timing. The adapter owner is responsible for replay safety. The new public client should prefer explicitSessionorTransportinjection while retainingset_http_adapter()as the global convenience API.HTTPConnectionas a compatibility wrapper or alias during migration, honoring the mutator contract (make_long_lived(),set_token(),_set_adapter(), raw.sessionaccess) described in the bootstrap section.BraintrustHTTPErrora subclass ofAugmentedHTTPErrorso existingexcept AugmentedHTTPError:handlers and user imports keep working (see "Structured errors").Incremental PR plan
Each PR should be independently mergeable and should avoid mixing code movement with behavior changes unless explicitly stated.
Planning PR: document the architecture and audit
Scope:
Behavior change: none.
PR 1: Extract the existing transport without changing behavior (merged in
625a5e77bcd0192bae4aa8e30c86e753d34187d2)Scope:
braintrust/api/_transport.py;HTTPConnectionandRetryRequestExceptionsAdapterout oflogger.py;stream=Trueresponses (see "Session sharing constraints"; changed only in PR 9);Behavior change: none.
Validation:
PR 2: Add structured errors and the policy-aware request engine (merged in
0cbf393e8992c756e3d10e16be4be9eb659c9abe)Scope:
errors.pyandpolicies.py;RetryMode.NONE,SAFE_READ,IDEMPOTENT_WRITE, andLOG_INGESTION;Retry-After, exponential backoff, elapsed-time limits, and deterministic sleep injection;Behavior change: none for existing endpoint calls.
Validation:
requests.Session,send(), orResponseobjects;Retry-After, and a delay larger than the remaining retry budget;Retry-Aftervalues are used directly without client backoff;NONE;SAFE_READ;PR 3: Add client bootstrap and service facade
Scope:
BraintrustClient,AuthAPI,EndpointRouter, andRequestTarget, without thread-wrapped async resource methods;is_universal_apiand the discovered URLs in the router without changing which host any request targets; switching traffic to the Universal API origin is a separate PR with release notes;BraintrustStatelazily own a client, with thread-safe lazy construction;BraintrustStateconnection methods and the module-levelapi_conn()/app_conn()/proxy_conn()functions as isolated legacy connections, honoring the mutator contract from the bootstrap section rather than pretending raw.sessionmutation can safely share the new client's session;make_long_lived()exactly aslogin()does today (bootstrap step 7) — so un-migrated call sites neither lose authentication nor their post-login request-exception retries;set_http_adapter()no-stacked-retries invariant while the legacy path remains.Behavior change: none.
Validation:
make_long_lived(),set_token(), andset_http_adapter()retain their characterized target- and timing-dependent behavior;PR 4: Add the experiment service with behavior parity
Scope:
ExperimentsAPI;Experimentmethods to the service;Noneon HTTP 400 ("no base experiment") instead of raising a typed error;SAFE_READso transient 429/5xx failures receive bounded retries.Behavior change:
Validation:
Nonewithout raising or retrying;Experiment.summarize()coverage rather than mockingExperimentsAPI;PR 5: Fix experiment summary failure semantics
Scope:
ExperimentSummaryas the authoritative representation:SummarySuccess,SummarySkipped, orSummaryFailed;scoresandmetricsmaps only onSummarySuccess, a reason onSummarySkipped, and structured error details only onSummaryFailed; preserve Braintrust response bodies verbatim and include only explicitly allowlisted response headers;ExperimentSummary.scoresandExperimentSummary.metricsas deprecated read-only bridges delegating to the nested result (the success maps onSummarySuccess, empty maps otherwise) and keep serializing them alongside the nested field —ExperimentSummaryis a public serialized dataclass whose fields user code, custom reporters, and CI tooling read directly, so removing them outright would break Goal 6; removal is a future major-version change;Decision:
Experiment.summarize()is strict by default and raises the typed API error when comparison retrieval fails;Behavior change: intentional and user-visible.
Validation:
SummarySuccess(scores={}, metrics=...);scores/metricsbridges mirror theSummarySuccessmaps, are empty for skipped/failed summaries, and stay in the serialized output;summarize_scores=FalseproducesSummarySkippedwith a reason;SummaryFailedonly in explicitly lenient mode, never an empty success;PR 6: Migrate safe read services
Split this into small PRs if review size warrants it.
Suggested order:
QueriesAPI: BTQL and prompt version queries.DatasetsAPI: lookup, environment resolution, fetch, and summary.PromptsAPI: prompt retrieval.FunctionsAPI: parameter/function metadata retrieval only.ProjectsAPI: project lookup and read-only metadata.AttachmentsAPI: metadata and download reads.Behavior change: transient safe-read failures receive consistent retries.
Validation:
PR 7: Make prompt and parameter fallback explicit
Scope:
LoadedResource[T]envelope containingvalue,source(api,memory_cache, ordisk_cache),cached_at, and an optional structuredfallback_error;load_prompt()keeps returningPrompt(Goal 6), and the load metadata is attached to the returned object as aload_infoattribute carrying the same fields; only newbraintrust.apisurfaces return theLoadedResource[T]envelope directly;staleboolean: for unpinned data the client cannot know whether a cached value is outdated, so expose only source, cache time, and the API failure that caused fallback.Behavior change: clearer errors and explicit cache-source metadata.
Validation:
PR 8: Migrate registration and deployment writes
Use separate service-level PRs for different retry safety classes.
Scope:
IDEMPOTENT_WRITE; variants that create secondary resources require their own classification;update=False(including/v1/datasetPOST): explicitlyIDEMPOTENT_WRITE; updates, including/v1/datasetPUT, require an audit-effect review before opting in;update=True(the/v1/experimentdefault whenensure_newis not true): conditionallyIDEMPOTENT_WRITE; unnamed registration,ensure_new=True, and all otherupdate=Falseregistration useNONE;NONE;NONEuntil idempotency exists;NONE;Behavior change:
Validation:
PR 9: Migrate function invocation
Scope:
RetryMode.NONEby default;Behavior change:
RetryRequestExceptionsAdapterdownloads the entire body even forstream=True, so streamed invocations are buffered in memory); this changes memory and latency behavior for streaming invocations and must be called out in release notes.Validation:
PR 10: Migrate attachment workflows
Scope:
RetryMode.NONE: the client-generated key prevents duplicate attachment identity, but the backend creates the status object with overwrite disabled and returns 409 on duplicate initialization instead of a fresh signed URL;If-None-Match: *upload header and addx-ms-blob-type: BlockBlobfor Azure;GET /attachmentforcontentLength/download metadata and then writingdoneif the persisted status still saysuploading. Interpret object-store precondition failures only after that check;Behavior change:
login()on the long-lived API connection, whose adapter retries connection failures up to 10 times), so a plain connection failure duringPOST /attachment— where the request never reached the backend and replay would be safe — now surfaces immediately. This must be called out in release notes; a future refinement could retry only failures known to have occurred before the request was sent, without waiting for the backend initialize/resume contract.Validation:
GET /attachment;If-None-Matchand Azure header behavior across supported object stores.PR 11: Isolate background log ingestion
Scope:
/versionprobe failure to be re-probed instead of permanently disabling overflow uploads for the process;Behavior change: ideally none initially; data-loss semantics should be changed only in a dedicated follow-up.
Validation:
PR 12: Remove the legacy transport path
Scope:
HTTPConnectionobjects inlogger.pyand consolidate Braintrust targets onto the policy-aware session only after all unsafe proxy calls have explicitNONEpolicies;set_http_adapter()scope, including the intentional removal of the old late-mutation quirk and disabling SDK retries whenever a custom adapter is present;Behavior change: completes adoption of explicit endpoint policies.
Validation:
Follow-up: stabilize and document the public API
The client is exposed through
braintrust.apias services are added:This is the convenience constructor: it performs login, organization selection, and URL discovery internally, then builds the transport-based client described in the facade section.
After the migrated services have seen internal and external use, perform a dedicated API review covering naming, pagination, model stability, whether a separate native
AsyncBraintrustClientis warranted, and compatibility guarantees. Add public reference documentation and examples for the supported service surface.Testing strategy
Prefer the highest-fidelity practical test for each behavior:
BraintrustClient, resource services, or transports with mocks in high-level tests.Retry-Aftertiming, and scripted 5xx recovery. Prefer this over mockingrequests.Session.send()or creating fakeResponseobjects.Additional rules:
Resolved decisions
Experiment.summarize()and framework-driven evals are strict by default. A summary retrieval failure raises the typed API error and fails the eval. Explicit lenient mode returns experiment metadata plus a failure-marked summary; the authoritative nested result never disguises failure as empty score or metric maps (only the deprecated top-level bridge fields stay empty, as they are today).ExperimentSummarycontains a nested discriminated comparison result as the authoritative representation:SummarySuccesshas score and metric maps,SummarySkippedhas a reason, andSummaryFailedhas structured error details. Braintrust response bodies are preserved verbatim and response headers use an explicit allowlist. Strict mode raises rather than returningSummaryFailed; that variant exists for explicit lenient mode. The nested result never encodes state through nullable or fake empty maps. The existing top-levelscores/metricsfields remain as deprecated read-only bridges (delegating toSummarySuccess, empty otherwise) and stay in the serialized shape until a future major version, so existing consumers of the public dataclass keep working.Retry-Afteris honored under the same rules as 429. Other 5xx statuses require an explicit endpoint override.SAFE_READpolicy permits four total attempts within 60 seconds of total monotonic elapsed time, including requests and sleeps, with a 20-second default per-attempt timeout so a first-attempt timeout still leaves room to retry. Timeouts and sleeps are clamped to the remaining budget.NONEoperations keep the current 60-second default timeout. Long-running endpoints require explicit policy overrides covering both per-attempt timeout and budget, and a policy whose budget does not exceed its per-attempt timeout is rejected.SessionorTransportinjection is preferred for the new client, whileset_http_adapter()remains the global convenience API.RetryMode.NONE; it may opt into retries later if the backend independently provides a verified idempotency contract.LoadedResource[T]with the value, source (api,memory_cache, ordisk_cache), cache timestamp, and optional structured fallback error. Braintrust response bodies are preserved verbatim and response headers use an explicit allowlist. Existing high-level loaders keep their return types —load_prompt()still returnsPrompt, with the metadata attached as aload_infoattribute — and only newbraintrust.apisurfaces return the envelope directly. API service methods remain cache-free. Nostaleboolean is exposed because the client cannot establish whether unpinned cached data is current.asyncio.to_thread()wrappers as async client methods. A future async API, if warranted, should be a separate nativeAsyncBraintrustClientwith dedicated transport, cancellation, streaming, timeout, and test coverage.Recommended first implementation slice
The smallest useful sequence is:
That sequence addresses issue #639 while establishing the architecture needed to safely migrate the remaining endpoints.