Troubleshooting#

The errors a run meets, by symptom: every error the client itself raises, plus the engine errors the guides document (the how-to pages quote the space and metric refusals they exercise). API-surface errors arrive as tauopt.ApiError with the message shape [<status>] <type>: <message>, the parsed fields on .status_code / .error_type / .message, and any machine-readable payload under .details. Terminal completion states additionally subclass tauopt.OptimizationComplete (see “The ask loop” below). Runnable examples for most of these flows live in python/examples/ (see the index).

The documentation tree (quickstart, how-to guides, reference, explanation) starts at the documentation index; this page is its error catalog.

tauopt-bench refusals: Run the benchmark suite.

Install and import#

ImportError: the local driver needs the math extras#

ImportError: the local driver needs the math extras: pip install 'tauopt[local]'
(or 'tauopt[all]' to include the GP strategies via 'tauopt[botorch]')

Cause: Client(driver="local") with only the featherweight install. The base package is httpx + pydantic by design; the in-process engine needs numpy/scipy. Fix, from a clone: pip install "./python[local]" for the space-filling strategies, or pip install "./python[all]" to include the Gaussian-process (GP) strategies (the PyPI name holds a placeholder during the closed beta).

Every suggestion reports strategy sobol/random (or the quickstart output is off)#

Cause: the GP extras are missing, and the never-fail ladder degrades SILENTLY. An unavailable rung (no tauopt[botorch] installed) is skipped without an error; the suggestion’s provenance records fallback_from but nothing raises. The same signature appears under sustained load in the default wall-clock budget mode: GP fit cost grows with observations and dimensions, and asks past the default timebox_s (10 seconds) degrade down the ladder. ucb_cl models the objective only, and sobol/random model nothing – outcome-constraint metrics are not enforced by fallback points. Fix: check tauopt.engine.strategies.registry.available() and suggestion.provenance.fallback_from; install ./python[all] from a clone; for load-driven fallbacks raise params.timebox_s, or pin timebox_mode="caps"/"off". To tell the causes apart, read the failed-rung records; through the Client they are plain dicts (None when no rung ran and failed):

# docs: no-run
for a in sug.provenance.fallback_attempts or []:
    print(a["rung"], a["exception_type"], a["exception_text"])

An unavailable rung (the extra not installed) is skipped without a record, so fallback_from names it while the list stays None; a rung that ran and failed leaves its TimeboxExceeded, its ValueError (for example GP strategies require at least one successful observation), or its numerics error in exception_text. Guide: Confirm the GP strategies run.

ValueError: hosted driver requires base_url and token#

Cause: Client() defaults to the hosted driver, which needs both connection arguments. Fix: pass base_url= and token=, or ask for the in-process engine explicitly with Client(driver="local").

ValueError: unknown driver 'x' (hosted|local)#

Cause: Client(driver=...) with a value other than "hosted" or "local". The Client constructor raises it before any request. Fix: one of the two names.

KeyError: unknown strategy 'x' (known: [...])#

Cause: a DIRECT tauopt.engine.strategies.registry.get() call with an unregistered name – this error does NOT fire through the Client. A typo’d strategy in config={"strategy": ...} never raises: the ask silently falls back through the whole ladder and answers from a lower rung, with the typo’d name recorded in suggestion.provenance.fallback_from. If your “gp_ei” run behaves like space-filling, check that field for a misspelling. The known set is random, sobol, gp_ei, ucb_cl, gp_nehvi (GP strategies also need tauopt[botorch] installed to run).

unknown params key 'num_restrats' for strategy 'gp_ei'#

[400] validation_error: unknown params key 'num_restrats' for strategy
'gp_ei' (did you mean 'num_restarts'?); accepted: acq_maxiter,
categorical_mode, failure_mode, fit_maxiter, noise_mode, num_restarts,
raw_samples, timebox_mode, timebox_s

Cause: a params key the named strategy does not read – a typo, or a key of another strategy. Unlike a misspelled strategy name, this refuses before any rung runs, on either driver and on every path (an explicit config, strategy: "auto", or a custom policy’s routed config): an unread key used to ride silently into the canonical config and move config_hash, so two runs meant to be identical replayed as different configs. sobol and random read no params at all (sobol reads no params). Fix: use a key from the accepted set (the per-strategy tables in the strategies reference), or move a field that exists only at the top level (seed, dedup, base_noise, center_in_bootstrap, duplicate_intents, queue_selection) out of params.

invalid params value for strategy 'gp_ei': num_restarts must be an integer, got 'eight'#

[400] validation_error: invalid params value for strategy 'gp_ei':
num_restarts must be an integer, got 'eight'

Cause: a params value the named strategy’s reader refuses – the wrong type ("eight", a bool where a count is expected, 1.9 for an integer key), a value out of range (num_restarts outside [1, 64], timebox_s outside (0, 300], mc_samples outside [16, 1024]), an unknown enumerated value (noise_mode, failure_mode, categorical_mode, timebox_mode), or a two-writer conflict (timebox_mode or categorical_mode set both top-level and in params; timebox_mode="off" with an explicit timebox_s; a cap field outside timebox_mode="caps"), or a composition of valid values the strategy cannot run on this space (categorical_mode mixed or mixed_capped on a conditional space; failure_mode="feasibility_gp_fixed" with either on a space with categoricals). Under strategy: "auto" the named strategy is the policy’s pick and the refused value is the top-level flag carried into it, and the refusal lands on the first ask the policy routes to a GP rung (the bootstrap’s sobol asks carry the flag inertly). Like an unread key, this refuses at the ask boundary before any rung runs, on either driver (on the hosted service a non-exempt user’s timebox-class refusal – timebox_s, timebox_mode, the timebox_mode two-writer pair, timebox_mode="caps" with timebox_s, and the params-form caps pointer; a cap field outside caps, the categorical pair and the caps-mode bundle reach ask() and answer the engine text on both planes – is answered by the service’s own config pre-check as config: <the same reader text>, without the strategy name): a refused value used to raise inside the never-fail ladder, and the ask was served by the next rung with provenance.fallback_from as the only tell, so a run that asked for gp_ei with eight restarts silently got another strategy. The ladder now descends on runtime failures only. Fix: a typed, range or enumerated refusal names the key, the value and the expected domain; a two-writer or posture conflict names the keys and the one-writer rule; the per-strategy tables in the strategies reference carry every domain.

Creating an experiment (400 validation_error)#

Guide: Add constraints and Optimize multiple metrics.

provide exactly one of metric | metrics#

Cause: both or neither of metric= / metrics= given. Single-objective uses metric={"name": ..., "objective": ...}; anything with thresholds, storage, or multiple objectives uses metrics=[...].

metric="loss" is refused: the bare string names no objective#

[400] validation_error: metric="loss" is refused: the bare string names no
objective, so pass metric={"name": "loss", "objective": "minimize"} or
{"name": "loss", "objective": "maximize"}

Cause: metric= given as a bare string. The shorthand used to expand to {"name": "loss"} and maximize the loss with no signal, so both drivers now refuse it before anything is created (the Client refuses it before any request leaves; the local driver’s own door refuses it too). Fix: pass the dict with an explicit objective. A dict without objective still maximizes, as before.

metric strategy/threshold require metrics mode: use metrics=[...]#

Cause: metric={..., "strategy": "constraint"} or a threshold in single-metric mode. Those semantics only exist in metrics mode; pass the same dict inside metrics=[...] instead.

constraint region too small: 0/4096 sobol probe points feasible#

Cause: the linear constraints exclude (essentially) the whole box, detected by a feasibility probe at creation so the failure is immediate rather than at the first ask. Fix: widen bounds or relax constraint thresholds. Note linear constraints range over plain (non-log, non-grid, unconditional) double parameters only.

num_solutions is single-objective only#

Cause: num_solutions > 1 with metrics mode. Multimetric best is the Pareto frontier (already a set); num_solutions selects k diverse top solutions for a single objective.

parameter 'batch': log_scale integers are not supported#

Arrives wrapped in pydantic’s report ([400] validation_error: 1 validation error for Parameter ...). Cause: log_scale=True on an integer parameter; log scale applies to doubles only. Fix: spell the parameter as an integer grid of the values you want ({"name": "batch", "type": "integer", "grid": [16, 32, 64, 128, 256]}), or as a log-scale double that the objective rounds (int(round(a["batch"]))).

Basic bounds#

name must be 1-255 characters, observation_budget must be >= 1, parallel_bandwidth must be >= 1, num_solutions must be >= 1 mean exactly what they say. Space and metric STRUCTURE are immutable after creation: update_experiment can change only name, budget, bandwidth, metadata, and metric_thresholds.

The ask loop (suggest)#

Guide: Run parallel workers and Pin a strategy and seed.

429 bandwidth_full#

[429] bandwidth_full: N open suggestions >= parallel_bandwidth=N; observe or
close one first

Cause: every bandwidth slot holds an open suggestion. This is the EXPECTED backpressure signal under parallelism (not a fault). Fix: observe or close an open suggestion, or create the experiment with a larger parallel_bandwidth. The hosted transport auto-retries 429 with backoff (honoring Retry-After, capped at 8 seconds per attempt); workers that want to wait longer implement their own retry loop (see python/examples/parallel_workers.py).

Hosted beta quotas (also 429)#

The hosted beta additionally enforces a suggestion quota (default 1,000 per rolling 24 hours; these 429s, quota_exceeded, carry Retry-After – after the transport’s own bounded retries give up, the raised error exposes the final response’s headers as e.headers and the parsed numeric delay as e.retry_after, so your own backoff loop can honor it) and an active-experiment cap (default 25; these 429s, experiment_quota_exceeded, carry NO Retry-After – waiting does not help). The 429 error types a client loop meets most are five (bandwidth_full, quota_exceeded, experiment_quota_exceeded, quota_unverifiable, rate_limited); their meanings are in the REST reference’s error-type table (quota_unverifiable: the quota store could not be read, so the request was refused rather than admitted unchecked; wait the 30 s Retry-After). The quotas never throttle reads or observations; the beta lane’s pre-authentication rate limit (150 requests per second, burst 300, every caller together) applies to every request on that lane and answers rate_limited with Retry-After 1; its per-credential sibling answers credential_rate_limited with Retry-After 1 past eight requests in flight from one token, 40 a second (burst 80), or 15 history-sized reads a second (burst 30). Fix for the experiment cap: archive finished experiments with client.archive_experiment(exp.id) (hosted: DELETE /v1/experiments/{id}). An archived experiment frees its cap slot but stays readable – experiment() (state becomes "deleted"), best(), and suggestion dismissals keep working; suggest/observe/update_experiment on it refuse with a 404. Guide: Archive experiments.

Admission and rate limits (hosted)#

At its 74e0202 build (the prod since 2026-10-31) the hosted service answers twelve more typed refusals a package-free loop or a worker fleet can meet, none of which the local driver has. Most are one line of When the service says no with its remedy, the rest are only here; the exact bodies are the service’s. 429 with Retry-After: 1: too_many_inflight_asks (a retry under the same Idempotency-Key already waits on one of your in-flight asks; the waiter cap is one), too_many_queued (eight of your asks already wait in your queue behind your in-flight bound – 8 unless your organization carries an override, which GET /v1/me reports), too_many_parked (your parked suggest permits reached your ceiling on the instance), suggest_rate_limited (asks faster than about ten a second), credential_rate_limited (more than eight requests in flight from one token, more than 40 a second, or more than 15 history-sized reads a second), rate_limited (the lane’s bucket, every caller together); and, with no Retry-After, token_quota_exceeded (a mint past the plan’s five live tokens; a web-app route: revoke one first). 503 with Retry-After: 1: queue_full (every suggest permit of the instance held), connection_pool_busy (every pooled database connection in use). 409: observation_cap_reached (the experiment holds its 4,000 observations), token_expired (a rotation of an expired token; revoke it and mint anew, a web-app route), idempotency_key_mismatch (an Idempotency-Key reused against another experiment: a new ask on another experiment needs a fresh key, a retry of the original ask keeps its experiment and its key). A package-free script sees these as the helper’s ApiError with the service’s error.type; the Python client raises its ApiError with e.error_type set to the same name.

3xx unexpected_redirect#

[301] unexpected_redirect: unexpected redirect (301) to 'https://...'; the
transport does not follow redirects - check base_url for a scheme/host that
redirects (e.g. http vs https)

Cause: the configured base_url answers with a redirect – typically an http:// URL bouncing to https://, a proxy in front of the service, or a moved host. The TauOpt API itself never redirects, and the transport refuses to follow one silently: a followed 301/302/303 would convert a POST into a GET, and cross-origin redirects drop the auth header.

Fix: point base_url at the final URL (usually: use https:// directly).

api_error (the reply was not the service’s JSON)#

ApiError with error_type api_error: the response body was not the service’s JSON error shape (a proxy’s HTML page, a plain-text 500), so the client reports the status with the first 500 characters of the body (or the reason phrase) as the message. Check the base URL (https://api.tauopt.com on the beta) and the service’s health before retrying; the message is the upstream text, not an engine error.

409 space_exhausted (raises tauopt.SpaceExhausted)#

[409] space_exhausted: the search space is fully explored -- every
input-feasible point has been suggested or observed (proof rides
details.dedup). Continue via an authorized replication intent, or read the
best point: the optimization is complete

Cause: COMPLETION (not failure). Every input-feasible point has been suggested or observed, and the engine PROVED it – the receipt is in details.dedup (exhaust_proof, proof_method, universe_size). Two proof methods exist: finite spaces up to 1024 raw points get an enumeration proof (conditional parameters included); larger all-finite spaces that are unconditional and unconstrained get a cardinality proof. Fix: nothing is broken. Catch tauopt.OptimizationComplete in the ask loop and read the best point; on noisy objectives, continue through an authorized replication intent (see “Intents” below).

409 unique_candidate_unavailable#

[409] unique_candidate_unavailable: no unique candidate available
(unique_candidate_unavailable); observe or close pending work before asking
again, or request an authorized duplicate via intents (refusals, if any, ride
details.dedup)

Cause: the duplicate-suppression repair ran dry WITHOUT an exhaustion proof – continuous axes, or large finite spaces with conditions/constraints (outside both proof methods above). This is NOT completion. A same-state re-ask returns the same answer deterministically, so retrying in a loop spins. Fix: observe or close pending suggestions first (the state must change), check details.dedup (unfilled_count, uniqueness_status), or request an authorized duplicate via intents (below).

Intents (the authorized-duplicate escape hatch)#

An intent is a typed request to re-evaluate a point the dedup default would otherwise refuse. Two kinds exist: variance_replication (re-evaluate an already-observed point – the noisy-objective continuation path at proven exhaustion) and liar_verification (re-ask a still-open point). Shape and example:

# docs: no-run
import json

source_id = json.dumps(obs.assignments, sort_keys=True, separators=(",", ":"))
sug = client.suggest(
    exp.id,
    config={"strategy": "auto", "duplicate_intents": {"variance_replication": {}}},
    intents=[{"kind": "variance_replication", "source_id": source_id, "ordinal": 0}],
)

The duplicate_intents block on the config enables the kind: each kind is disabled by default, and an intent whose kind has no block is refused as disabled. source_id is the canonical assignment key of the point (sorted keys, compact separators), never an observation or suggestion id: an id there is refused as source_not_in_q. Intents are validated against the recomputed policy, never trusted; refusals ride the suggestion’s provenance.dedup record. They require the dedup default: a config={"dedup": False} ask that also passes intents= answers 400 (intents require dedup=true on the config). Guide: Use duplicate intents.

503 suggest_timeout#

Cause: on_timeout="fail" and either a GP rung’s timebox_s or the ask’s max_wait_s expired (budget or off mode; in caps mode the deadline is AskDeadlineExceeded, see below) – an honest timeout so you can run your own backoff. Hosted responses carry Retry-After: the hosted client raises the first 503 (a POST is not retried on this 503), and the raised ApiError exposes that response’s headers as e.headers and the parsed delay as e.retry_after, so your own backoff loop can honor it. max_wait_s accepts a number in (0, 300]. The default on_timeout="ladder" instead degrades down the fallback ladder (gp_ei -> ucb_cl -> sobol -> random; multimetric: gp_nehvi -> sobol -> random) and records the fallback in the suggestion’s provenance rather than raising.

503 ask_deadline_exceeded (raises tauopt.AskDeadlineExceeded)#

[503] ask_deadline_exceeded: ask exceeded max_wait_s=50.0 before gp_ei could
run (caps mode: rung-boundary refusal, no substitution)

Cause: a config carrying timebox_mode: "caps" and an expired max_wait_s. In caps mode the deadline is a refusal, not a ladder step: it fires at a rung boundary (before the rung runs) or at the single delivery decision (after the work, the computed drafts discarded), in either on_timeout mode, and no cheaper rung substitutes. No suggestion opens and no bandwidth slot is taken. The raised AskDeadlineExceeded is an ApiError on both drivers; the refusal receipt rides e.point (rung_boundary or delivery), e.max_wait_s, e.elapsed_s, e.rung, and e.details with the same four keys (plus, on the hosted plane, any further key the service’s body carries beside them). On the hosted plane an ask that first waited for an ask slot reaches the engine with that wait already taken off its budget: the receipt’s max_wait_s is the budget the engine was handed after the wait (the figure you sent, less the queue wait) and elapsed_s counts from the engine’s start. There is deliberately no Retry-After (e.retry_after is None): a caps re-ask repeats the same deterministic work, so waiting fixes nothing. Fix: raise max_wait_s, lower the work caps (fit_maxiter, acq_maxiter), or pin a cheaper strategy. Guide: Pin a strategy and seed.

A third e.point, backstop, is the supervisor’s refusal (tauopt.BackstopExceeded, a subclass): the ask did not answer within max(1.25 * max_wait_s, max_wait_s + 3 s) – a kernel that never returns, not the engine’s own clocks – so the local driver’s worker process was killed and replaced behind the response (the hosted plane does the same on its queue lane’s worker pool, provenance.lane worker; POSIX only – on Windows the local driver runs the ask inline with no backstop). e.stage says where: ask (the worker was running the ask), warmup (the worker’s imports and handshake had not finished; the replacement warms behind the refusal, and the next ask waits for it inside its own budget – it can refuse at warmup again if the imports take longer than that backstop), worker_died (the worker crashed or was killed, e.details["exit_code"], or died three times before its handshake or could not be started, e.details["spawn_failures"] and e.details["spawn_error"]; its stderr is on your terminal; close() then ask again retries), reap (three retired workers refuse to die, e.details["unreaped_pids"] and e.details["reaping"]: restart the process), abandoned (eight pipe threads abandoned, e.details["abandoned"], or a backstop thread that could not be started: restart the process). No suggestion opened and the state is unchanged. Fix: raise max_wait_s if the ask is slow rather than hung, pin a cheaper strategy, or Client(driver="local", supervisor="off") to run the ask inline as before.

503 experiment_busy (hosted)#

[503] experiment_busy: another request holds this experiment; retry

Cause: another request holds the experiment’s advisory lock – a running ask on a worker, usually (every ask runs under it, one at a time; so do an update, an archive and the two dismissal routes – the four requests that can be refused this way; by contrast, an ask that finds the lock held waits for it on its worker and is re-admitted, never refused, and an observe never answers it) – and the 3 s bounded wait expired. Hosted only (the local driver is one process with no lock). The answer carries Retry-After: 1 (e.retry_after). The hosted transport retries it for you on every method (it is in the transport’s pre-commit set, so a POST retries it too: experiment_busy, queue_full, and ask_slots_busy, retired at deploy 2 with the lane it guarded but kept in the set) – up to five attempts honoring the Retry-After, so the ApiError surfaces only when those run out. Fix: wait and re-send the same call – the refused update, archive or dismissal did nothing, so there is nothing to reconcile.

500 all_rungs_failed, integrity_violation, internal_error#

[500] all_rungs_failed: no strategy could produce a suggestion (intended
'gp_ei') -- attempts: gp_ei[fit] ValueError: ...; ucb_cl[fit] ValueError: ...;
sobol[-] ValueError: ...; random[-] ValueError: linear constraints rejected
100000 consecutive random draws -- feasible region too small to sample

Cause: the engine’s own failure, never the request’s. all_rungs_failed means every rung of the fallback ladder raised, the stdlib-only random rung included (its one refusal is the rejection cap on a pathologically thin linear-constrained region); e.details["attempts"] carries one record per failed rung (rung, stage, exception_type, exception_text). Fix: for the constrained-region case, widen the bounds or relax the constraint and re-create the experiment; otherwise read the attempts. integrity_violation means a postcondition failed after a rung answered (e.details["check"] names it) and internal_error means an engine-side ValueError that is not the caller’s (e.details["exception_type"]); both are defects to report with the message. Both drivers answer these shapes; the hosted transport does not retry a POST 500 (a POST retries only a 429 and the transport’s pre-commit set), so the error arrives at once. In earlier engine versions the AskFailed cases escaped the local driver as a raw RuntimeError and answered a bare 500 api_error on the hosted plane, an engine-internal ValueError was blamed on the caller as a 400, and with dedup on (the default) the all-rungs-failed case read as a 409 unique_candidate_unavailable.

ValueError: on_timeout must be 'ladder' or 'fail', got 'x'#

Cause: client.suggest(..., on_timeout=...) with a value other than "ladder" or "fail". The Client raises it before any request is sent. Fix: one of the two names ("ladder" is the default).

“suggest keeps returning the same point”#

Applies to direct tauopt.ask() users and config={"dedup": False} asks (strategy defaults to "auto"): suggest is a pure function of (state, config, the ask’s intents), so until the state changes – an observation lands, or an open suggestion is closed – the same ask returns the same answer by design. Fix: report or close before re-asking. (Through the Client with the dedup default, this symptom does not occur: every suggest opens a suggestion, which advances state, and pending points are excluded – the visible signals there are 429/409 instead – with one edge: a suggestion you close WITHOUT observing leaves the dedup universe (the set of observed and open points the engine never re-suggests), so the same point can lawfully return on a later ask.)

Reporting results (observe)#

Guide: Report failures and noise.

provide exactly one of suggestion_id | assignments#

Cause: both or neither given. Observe a suggestion by its id, or report a free-form point by its assignments.

provide value(s) XOR failed=true#

Cause: a result and a failure flag together (or neither). A failed evaluation carries failed=True and no value; a successful one carries value= (single metric) or values={...} (metrics mode, every optimize/constraint metric required).

observation value must be finite, got nan#

Arrives wrapped in pydantic’s report ([400] validation_error: 1 validation error for ObservationRecord ...); in metrics mode the line names the metric (observation values['loss'] must be finite, got nan). Cause: a NaN or inf result, the usual signature of a diverged run. Values are checked at the door, never discovered inside a fit. Fix: report the evaluation as failed instead (failed=True, no value), which keeps the point in the dedup universe and out of the model.

409 conflict: suggestion '...' already has an observation#

Cause: double-reporting the same suggestion (the local driver mirrors the hosted unique index). Fix: one observation per suggestion; use a fresh ask, or report a free-form point via assignments=.

409 suggestion_expired (hosted)#

[409] suggestion_expired: suggestion 11 expired at 2026-10-23T00:00:00+00:00
after 86401 s open (the experiment's suggestion_ttl_s; a later suggest expired
it and reissued its slot). Report the result as an unsolicited observation
with details.assignments, or raise suggestion_ttl_s for longer evaluations

Cause: the suggestion stayed open longer than the experiment’s suggestion_ttl_s (the service default is 86400 s), a later ask on the experiment closed it (its expired_at is set; suggestions(exp.id) shows it closed) and reissued its bandwidth slot, and then the observation arrived. Hosted only: the local driver records suggestion_ttl_s and never expires a suggestion (expired_at is always None there). e.details carries suggestion_id, expired_at, created_at, and the assignments. Fix: report the result as a free-form point, observe(exp.id, assignments=e.details["assignments"], value=...), which keeps the evidence; for long evaluations create the experiment with a larger suggestion_ttl_s or move it with update_experiment(exp.id, suggestion_ttl_s=...) (0 never expires; the new value applies from the next ask). Guide: Run against the hosted service.

404 not_found#

no experiment '...' / no suggestion '...' (the local driver’s wording; the service’s 404 messages differ): the id does not exist in this driver instance. The local driver’s state lives in the process – ids do not survive a restart (recovery recipe below).

After a restart (local driver)#

Local state is in-memory only. To survive a crash or a wall-time kill, log the FULL payload of every observe you send – assignments plus value/values, any stddevs, and failed=True rows (failed points are part of the dedup universe too) – then rebuild: re-create the experiment with the same definition and replay the log through observe(assignments=..., ...) in order (no suggestion ids needed). The rebuilt experiment carries the same evidence and the next suggest is deterministic on it; open suggestions and dismissals from the dead process are not part of such a log, so the continuation is equivalent-in-evidence, not byte-identical to a run that never died. Guide: Replay a suggestion for the rebuild, and Archive experiments for what stays readable while the process lives.

Reproducibility#

Guide: Replay a suggestion and Pin a strategy and seed.

Same machine + same dependency versions => bit-identical runs: suggest is deterministic per (state, config), seeds derive from state hashes, and every suggestion’s provenance records (engine_version, engine_sha, deps_fingerprint, strategy, config_hash, seed). The fine print:

  • Across CPU classes or torch/BoTorch builds, expect numeric drift in GP-heavy paths – pin the machine class when exact replay matters.

  • The default wall-clock budget mode is pure GIVEN a non-binding timebox: on a loaded machine a binding timebox can trigger a recorded ladder fallback. Pin "timebox_mode": "caps" (or "off" with no ask-level max_wait_s) for load-independent replay.

  • GP fit/acquisition compute is single-threaded BY DESIGN: the engine pins torch to one thread for the duration of the suggest step and restores it after (a fixed BLAS schedule for bit-determinism), so thread count does not enter the engine’s bit-identity envelope – and a suggest never oversubscribes a shared node. Your own evaluation code still uses whatever threads you allow it.

  • To replay a suggestion exactly: rebuild the experiment state up to that ask (your recorded observations, in order), verify the environment against the receipt’s deps_fingerprint, and re-ask with the same config. The receipt alone identifies a run; replay needs your recorded state.

  • For policy-free research runs pin everything explicitly: config={"strategy": "gp_ei", "seed": 7, "timebox_mode": "caps"}.

Engine-level exceptions (direct tauopt.ask users)#

Driving tauopt.ask(state, config=...) directly (no Client) surfaces typed engine errors instead of ApiError: TimeboxExceeded (wall budget exceeded with on_timeout="fail") and ValueErrors from config/space validation plus STATE-READ preconditions (single-objective reads refuse multimetric states: use pareto_frontier(), not best). The one left out of that list is AskDeadlineExceeded (a caps-mode request deadline made the answer unavailable – refused, never substituted): an engine error that already IS the 503 ask_deadline_exceeded ApiError both drivers raise. UniqueRepairExhausted and QueueUnsupported never reach you: the ask boundary catches both. A dedup repair that runs dry keeps the slots it filled and descends the ladder for the rest, leaving a fallback_attempts entry and the hop in provenance.dedup.ladder_hops (per_draft_origin names the rung that produced each returned draft); a strategy that cannot serve a queue_selection block answers through its plain repair path, and each such slot appears in provenance.selection with origin pass_through. Strategy-level preconditions do NOT surface through ask() either: a GP rung with zero successful observations raises inside the rung, the ladder catches it and descends (recorded in fallback_attempts). The same catch takes the final rung’s one refusal: on a pathologically thin constrained region, random’s capped rejection scan raises linear constraints rejected 100000 consecutive random draws, and the creation-time feasibility probe screens out most such regions. What climbs out when every rung has failed is one tauopt.engine.AskFailed (a RuntimeError) with error_type all_rungs_failed:

AskFailed: no strategy could produce a suggestion (intended 'gp_ei') -- attempts: ...

followed by an attempts summary (one rung[stage] ExceptionType: text entry per failed rung, the same evidence fallback_attempts carries); its details carries intended and the attempts records, and its __cause__ is the last rung’s exception. Through the local driver it is the 500 all_rungs_failed ApiError described under “The ask loop” (and the same through the hosted plane). It means every rung failed; read the attempts, and for the constrained-region case widen the bounds or relax the constraint and re-create the experiment. The same class carries error_type integrity_violation when a postcondition fails after a rung answered (a center-flagged sobol answer without a center record, or queue-selection drafts that disagree with the selector’s ledger); details names the check. The caller’s own argument errors (n < 1, a bad max_wait_s, auto with params, intents without dedup, a params key the strategy does not read) are tauopt.AskValidationError (a ValueError), the one class the drivers answer as 400. AskDeadlineExceeded reaches Client(driver="local").suggest callers as the 503 ask_deadline_exceeded ApiError it is, exactly as the hosted driver raises it – one except ApiError sees it, or catch the class itself alongside OptimizationComplete in caps-mode loops.

Hosted transport#

Guide: Run against the hosted service.

Retries are bounded and honor Retry-After (capped at 8 seconds per attempt): 429 always retries (the server rejected the request; nothing committed); 500, 502, 503, and 504 retry on non-POST methods; a POST also retries a 503 whose typed body names one of the transport’s pre-commit set – experiment_busy, queue_full, and ask_slots_busy, retired at deploy 2 with the lane it guarded but kept in the set – each a refusal before any suggestion or observation is committed, with Retry-After – and no other 5xx (connection_pool_busy included: the pooled connection is released at commit, so a post-commit read can answer it for a row already written). A POST that dies with any other 5xx, or with no answer at all, may have committed server-side – check state before re-sending rather than blind retrying: client.suggestions(exp.id, state="open") and client.observations(exp.id) list what the server holds (both work on archived experiments), and client.experiments() lists your active experiments (newest first, count is the active total) after a lost create_experiment. client.health() and client.auth_check() probe the service and the token first.

Cannot connect / auth failed#

Connection-level failures raise RAW httpx exceptions and are NOT retried: httpx.ConnectError means the base_url or network is wrong, or the TLS handshake failed (CERTIFICATE_VERIFY_FAILED in its text, with the reason after it: unable to get local issuer certificate means the host’s chain is not in your Python’s trust store, which a python.org macOS build fixes by running its Install Certificates.command once; an expired certificate or a hostname mismatch is the service’s to fix, so report it; the package-free scripts surface the same failure as urllib.error.URLError); httpx.ReadTimeout means the server did not answer within the read timeout: timeout_s (default 60 seconds) on every route but the suggest POST, which listens for the backstop of its max_wait_s plus 40 s whenever that exceeds timeout_s (415 s with no deadline; at the default timeout_s a hung ask with no deadline, or one above 240 s, then ends in the platform’s 504, an ApiError, on a service without the queue and worker supervisor; a smaller deadline still ends here, at the derived bound), so a long ask never needs a raised timeout_s; siblings (WriteError, PoolTimeout, protocol errors) escape the same way. The transport does not follow redirects: an http:// base_url against an https-only service raises [301]/[302] unexpected_redirect (see “3xx unexpected_redirect” above) – use the exact scheme and host you were given. A [401] unauthorized ApiError means the token was rejected: it is unknown, revoked, or issued for another lane (beta tokens are lane-scoped and are shown once, when minted on the web app’s tokens page). The service answers every token failure with 401; its 403s are forbidden (the admin API’s door only, never a /v1 route), origin_refused (the pre-authentication gate’s Origin check) and account_pending / account_disabled (the web app’s sign-in).