# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- A circuit breaker in `window` mode now starts an empty window when a half-open trial closes it. Before, the failures that opened the breaker stayed in the window, so with a `resetTimeoutMs` shorter than `window.sizeMs` a single new failure could open it again before `minimumRequests` new outcomes came in. `circuits()` reports `failures: 0` right after the circuit closes.

## [1.1.0] - 2026-10-17

### Added

- `defaultPartition` client option: a `PartitionConfig` for every partition not listed in `partitions`, so many hosts can be tuned without listing each one. Merge order stays client → partition → request; a listed partition does not inherit from `defaultPartition`. Unset fields keep the built-in `concurrency: 5`, `maxQueueSize: 100` (#141).
- `client.circuits()` returns a `CircuitSnapshot` for each enabled circuit breaker: its `partition`, `state` (`"closed"` | `"open"` | `"half_open"`), `failures`, and, unless closed, `openedAt`/`nextAttemptAt`. It's `[]` when no breaker is configured. Reading it never changes the breaker's state or keeps an idle partition alive (#140).
- `@vereda/http/otel` entry point: `instrumentTracing(client, { tracer })` records one span per ticket and one child `CLIENT` span per attempt using the HTTP semantic conventions, injects `traceparent` into outgoing requests (`propagate: false` to opt out), and keeps credentials and, by default, query values out of `url.full`. `otelMetricsSink(meter)` records vereda's metrics as OpenTelemetry counters, a histogram and observable gauges. `@opentelemetry/api` is an optional peer dependency that only this entry point imports (#147).
- `redirect` client option (`"follow"` | `"manual"`, default `"follow"`), set on every attempt's fetch init and exposed to middleware as `ctx.redirect`. fetch's `"error"` mode is rejected: its `TypeError` on a 3xx is indistinguishable from a network failure, so redirects would be retried and counted against the circuit breaker. With `"manual"`, a 3xx comes back as an `HttpError` whose `response` carries the `Location` header, so callers can vet each hop themselves.
- Re-export `BulkheadSnapshot` from the public entry point `@vereda/http` (#138).
- `parse` may be async: `ParseFn<T>` is now `(data: unknown) => T | Promise<T>`. A thrown error or rejected promise becomes a `ValidationError` and is never retried. The wait for `parse` counts against `attemptMs`/`totalMs`, so a parse that never settles ends as a timeout, and `cancel()` ends it too (#142).
- A partition, or `defaultPartition`, can set `retryBudget: false` or `rateLimit: false` to turn off a budget or rate limit set on the client. Any other non-object value is a `ConfigurationError` (#190).
- `rateLimit` client and partition option (`{ tokensPerSec, burst?, maxWaitMs? }`): a token-bucket rate limit per partition. Every attempt, first attempts and retries alike, takes a token before it is sent, and waits for one when the bucket is empty. The wait is abort-aware: `cancel()` and `timeout.totalMs` end it, and the token goes back to the bucket. `burst` defaults to one second's worth (`Math.max(1, tokensPerSec)`). Without `maxWaitMs`, an attempt waits as long as it needs to; with it, an attempt that would wait longer fails at once with the new `RateLimitedError`. The wait counts toward `queuedMs`, and `partitions()` reports the free tokens as `tokens` for rate-limited partitions. A `429`'s `Retry-After` still delays only that ticket's own retry. Opt-in: no limit applies unless one is configured (#145).
- `RateLimitedError` (`kind: "rate_limited"`, with `partition`, `waitMs`, `maxWaitMs`), never retried. This adds a member to the `AppError` union, so a `switch` over `error.kind` that ends in a `never` check needs a `"rate_limited"` case to compile (#145).
- `BulkheadSnapshot.partition`: the partition's name, matching `CircuitSnapshot.partition`. Code that builds a `BulkheadSnapshot` by hand (a test double, say) needs the new field to compile; code that only reads `partitions()` is unaffected.
- `QueueFullError.scope` (`"partition"` | `"global"`) tells which queue was full. Check it instead of `partition === "global"`, which a partition named `"global"` would also match.
- `retryBudget` client and partition option (`{ ratio, windowMs, minRetriesPerSec? }`): a per-partition retry budget that caps retries to a fraction of recent traffic, as in gRPC and Finagle. A retry is allowed only while the partition's retries over the last `windowMs` stay below `max(minRetriesPerSec * windowMs / 1000, ratio * firstAttempts)`; `minRetriesPerSec` defaults to `10` (`DEFAULT_MIN_RETRIES_PER_SEC`). Past that, the ticket fails right away with its last error, after a new `retryBudgetExhausted` event (`{ ticketId, url, partition }`) and the `vereda.retry_budget_exhausted` counter. Opt-in: no budget applies unless one is configured. Under a retry storm, a host sees at most about `1 + ratio` times its normal load (#144).
- `responseType` request option (`"json"` | `"text"` | `"arrayBuffer"` | `"blob"` | `"none"`): how the body is read inside the attempt before `parse` sees it. It defaults to `"json"` when `parse` is set. Without `parse`, `data` is the body read that way, typed as `unknown`, `string`, `ArrayBuffer`, `Blob` or `undefined`. `"none"` cancels the body unread. With neither option set, the body is still handed back unread on `raw`. An unknown value fails the ticket with a `ConfigurationError` (#142).

### Fixed

- `timeout.attemptMs` and `timeout.totalMs` above 2^31 − 1 ms (about 24.8 days) are now rejected with a `ConfigurationError`. Before, `setTimeout` clamped such a delay to 1 ms, so a 35-day `attemptMs` timed out every attempt almost at once. `Infinity` still means no timeout. Waits vereda computes itself, a retry's backoff or capped `Retry-After` and a rate-limit token wait, now last their full length past that limit instead of ending after ~1 ms, so `backoff.maxDelayMs` and `rateLimit.maxWaitMs` need no upper bound (#203).
- `close({ drain: true, timeoutMs })` now rejects with a `ConfigurationError` when `timeoutMs` is not a finite number in `(0, 2_147_483_647]`. Before, `Infinity` and anything above 2^31-1 ms slipped past the check and Node clamped the drain timer to 1 ms, so in-flight tickets were cancelled almost at once instead of drained. A rejected call leaves the client open, so a corrected `close()` still drains (#198).
- A throwing `logger` (or `requestLogger` `log` function) no longer changes a request's outcome. Before, a logger that threw on `Request succeeded` turned a successful response into a `NetworkError` failure and fired both `success` and `failure`. Logger errors are now isolated like listener and metrics errors: reported on a microtask, never affecting the result, the events or retries (#197).
- A request whose `signal` was already aborted no longer emits `failure` while its ticket resolves as `CancelledError` when it also fails an early check (an unresolvable URL, an invalid body or option, an open circuit breaker, a rate limit). It now emits a single `cancelled` event, matching the result, and never reaches the circuit breaker or the rate limiter, and no `request` event fires for it. (#196).
- An attempt whose transport ignores the abort signal (a custom `fetch` that drops `init.signal`, or middleware that swaps `ctx.signal`) now ends as soon as the attempt is cancelled or times out. Before, `await fetch` never returned: `cancel()` resolved the ticket but no `cancelled` event fired, and `attemptMs`/`totalMs` never resolved the ticket at all. A response that arrives after the attempt was abandoned has its body cancelled (#181).
- Reject forbidden HTTP methods (`TRACE`, `CONNECT`, `TRACK`) up front with a `ConfigurationError` during request validation instead of letting them enter the retry loop client-side (#139).

### Changed

- The `cancelled` lifecycle event's `partition` is now typed `string | undefined`, as on `failure`. It is `undefined` only for a request cancelled by an already-aborted `signal` before its URL could be resolved. TypeScript code that uses it as a `string` (for example as an object key) needs a fallback (#196).
- `withZod` validates with `parseAsync`, so schemas with async refinements or transforms work. Calling the returned function directly now gives `T | Promise<T>` (#142).
- A body stream that fails mid-read is now reported as `NetworkError("Failed to read response body")` instead of `"Failed to parse response body as JSON"`, since the body is no longer always read as JSON (#142).
- A retry whose delay (backoff or capped `Retry-After`) would run to or past `timeout.totalMs` is no longer slept on: the ticket fails right away with `DeadlineExceededError`, whose `cause` is the last attempt's error. Before, it slept until the deadline and then failed with the same error. `DeadlineExceededError` can therefore arrive before `totalMs` has elapsed (#143).
- A full global queue's `QueueFullError` message now reads `Global queue is full (n/max)` instead of `Queue for partition 'global' is full (n/max)`.
- The error hierarchy is now documented as open to additions: a minor release may add an error class and `kind`, always listed in this CHANGELOG, while existing classes, kinds and fields change only in a major. The docs now recommend a `default` branch over a `never` check when switching on `error.kind`.
- No more `node:events` or `node:crypto` imports, and timers are `unref()`'d only where the runtime supports it, so the package loads on Cloudflare Workers without `nodejs_compat`, and on other runtimes without Node builtins. Lifecycle and ticket events now use a small internal emitter that keeps the same behavior (a listener added twice runs twice, `off` removes the most recent registration, and a throwing listener can't break the others). A CI smoke test runs the built package in workerd (#146).

### Deprecated

These go away in 2.0 (tracked in #148):

- `BulkheadSnapshot.name`: use `partition`, which holds the same value.
- `QueueFullError.partition` set to `"global"` for the global queue: check `scope` instead. In 2.0, `partition` is absent when `scope` is `"global"`.
- A `circuitBreaker` config without `enabled: true` being inert. In 2.0, setting `circuitBreaker` turns the breaker on, as `retryBudget` and `rateLimit` already do, and only `enabled: false` turns it off. Pass `enabled` explicitly, or remove an unused config, to keep your behavior.

### Documentation

- New `docs/book/`: The Vereda Book, thirteen chapters on how the client works inside, following one request (`GET /users/42`: a hang, a 503, then a 200) through the ticket, the retry loop, isolation, the circuit breaker and retry budget, observability, the invariants and the bugs that taught them, and testing and release. Linked from ONBOARDING.md.
- README documents the `fetch` client option, with a security note: automatic redirects bypass dispatcher-level DNS guards for IP-literal targets, so SSRF-sensitive callers should use `redirect: "manual"` and follow hops themselves.

## [1.0.2] - 2026-10-07

### Fixed

- `timeout.attemptMs` and `timeout.totalMs` did not bound a `parse` body read when the transport ignored the attempt's abort signal (a custom `fetch` that doesn't forward `signal`, or middleware that replaces `ctx.signal`). The ticket stayed pending while the server kept sending, and cancelling it left the read running. Vereda now reads the body itself and stops the read when the attempt aborts. An attempt that times out or is cancelled after `fetch` resolved also cancels the unread body, so the connection is released (#133).
- The `vereda.in_flight` gauge could stay at `1` after the client went idle. It was read when the terminal event was emitted, and requests that finished on the first attempt (success, non-retryable failure, cancellation, configuration error) emitted before leaving the in-flight set, while retried requests emitted after it. The gauge is now reported when a ticket leaves the in-flight set, so it returns to `0` on every path (#134).

## [1.0.1] - 2026-10-01

### Fixed

- **Idle-partition cleanup could double a partition's concurrency cap.** After 60s without a new request, a partition's bulkhead was discarded even if requests were still running, queued, or waiting to retry, so the next request got a fresh bulkhead and the old and new ones each admitted up to `concurrency` requests. A partition is now kept while any request still uses it.
- **Idle-partition cleanup silently reset open circuit breakers.** An open breaker idle for 60s was discarded and replaced by a closed one, so a `resetTimeoutMs` longer than 60s never took effect. An open breaker is now kept until its `resetTimeoutMs` has elapsed. After that, if the partition stays idle, it is discarded so breakers for hosts that are never contacted again don't accumulate.
- Timeouts were detected from the shape of the thrown error instead of from which signal fired. A timeout during middleware that wraps or rethrows the abort error was reported as a `NetworkError`, and an `AbortError` that no Vereda signal caused (e.g. from middleware's own abort logic) was reported as a `TimeoutError`. Classification now follows the attempt's own timeout signal: the first case is a `TimeoutError` and the second a `NetworkError`.
- Backoff sleeps between retries and per-attempt (`attemptMs`) timers kept the Node.js process alive. They are now `unref()`ed, like the `totalMs` deadline timer, as `docs/operations.md` already described.

## [1.0.0] - 2026-09-25

Vereda's first release published to npm, as `@vereda/http` (npm rejected the unscoped name `vereda` as too similar to an existing package). Before this, there was no published
package: the project (then named `relay`) was consumed by pointing a
dependency straight at a GitHub ref, where a `prepare` script compiled
`dist/` on install and there were no semver guarantees. The entries below
summarize the real, user-visible changes accumulated across that pre-1.0
development — most relevant if you were depending on a GitHub commit/branch
of this project before today.

### Added

- The package ships `src/` next to `dist/`, so source maps and declaration maps resolve: stack traces point at the original TypeScript, and go-to-definition opens the source instead of the `.d.ts`.
- Structured error hierarchy: every failure is a `RequestError` subclass with
  a discriminated `kind` — `NetworkError` (`network`), `HttpError` (`http`),
  `RetryableStatusError` (`retryable_status`), `TimeoutError` (`timeout`),
  `DeadlineExceededError` (`deadline`), `ValidationError` (`validation`),
  `CancelledError` (`cancelled`), `QueueFullError` (`queue_full`),
  `ConfigurationError` (`configuration`), `MaxRetriesExceededError`
  (`max_retries`). `ticket.toPromise()` never rejects — failures come back as
  a `Result` union over this hierarchy (#29).
- `retryWhen` predicate, consulted after **every** attempt including attempt
  0; it can only veto a retry the default policy would otherwise allow, never
  force one (#32, #33).
- `Retry-After` (seconds or HTTP-date) is honored and capped at
  `backoff.maxDelayMs` instead of always falling back to configured backoff.
- Per-host bulkhead partitions (`partitions` config) with per-partition
  `concurrency` / `maxQueueSize` / `retry` / `timeout` overrides. The first
  attempt always skips the bulkhead; only retries are throttled.
- Graceful shutdown: `client.close({ drain, timeoutMs })` waits for in-flight
  tickets before cancelling the rest; new requests after `close()` throw
  `ConfigurationError("client closed")` (#33).
- Lifecycle events (`request`, `retry`, `success`, `failure`, `cancelled`) via
  `client.on(...)`, enriched with `attempts`, `durationMs`, `queuedMs`, and a
  pluggable `MetricsSink` (`counter`/`histogram`/`gauge`) driven by the same
  events (#44).
- Structured logging with automatic query-string redaction
  (`ClientConfig.redactQuery`, default `true`) so secrets in URLs don't leak
  into logs or lifecycle events (#44).
- Injectable `fetch` (`ClientConfig.fetch`) for tests and custom transports,
  threaded through every attempt including retries (#44).
- Middleware onion model (`client.use(...)`), with `defaultHeaders()` and
  `requestLogger()` helpers shipped from the `@vereda/http/middleware` entry point.
- `withZod()` response-validation adapter shipped from the optional
  `@vereda/http/zod` entry point (zod stays an optional peer dependency; only this
  module imports it).
- `HEAD` / `OPTIONS` / `PUT` / `PATCH` / `DELETE` convenience methods
  alongside `get`/`post`, and a `json<T>()` identity parse helper — the API
  freeze that closes out the pre-1.0 surface.
- Replayable request bodies via a factory (`body: () => BodyInit`), invoked
  fresh per attempt; a raw `ReadableStream` body is a `ConfigurationError`
  instead of silently replaying an already-consumed stream (#33).
- `publint` and `@arethetypeswrong/cli` verify the published package shape in
  CI; `dist/` ships alongside `README.md` and `LICENSE`.
- Per-partition circuit breaker (opt-in via `circuitBreaker: { enabled: true }`), mirroring the bulkhead registry: trips on consecutive failures (default) or a rolling failure-rate window, then half-opens after `resetTimeoutMs` to trial recovery. Rejects immediately with the new `CircuitOpenError` — no attempt is made while open (#60).
- Real `queuedMs` on `success`/`failure`/`cancelled` lifecycle events — total time a ticket spent waiting for a bulkhead/global-semaphore permit, summed across all attempts (previously always `0`). New `vereda.global_queue_depth` gauge reports the global concurrency cap's (D1) wait-queue backlog; `vereda.queue_depth` (declared previously but never emitted) now reports real per-partition backlog. See [Wiring a metrics sink](docs/operations.md#wiring-a-metrics-sink) (#72).

- `redactUrl` is exported from `@vereda/http`, for redacting URLs in your own logging the same way the client does. `requestLogger()` accepts `redactQuery` (default `true`).
- `Semaphore.acquire()` and `Bulkhead.run()` accept an `AbortSignal`: a waiter whose signal aborts leaves the queue.
- `vereda.retries` and `vereda.duration_ms` are tagged with `partition`, so retry volume and latency can be broken down per host. The `retry`, `success`, `failure` and `cancelled` lifecycle events carry `partition` too, like `request` already did. On `failure` it is `undefined` only when the URL couldn't be resolved, and `duration_ms` omits the tag in that case.

### Changed (breaking, relative to pre-1.0 GitHub installs)

- Requires Node.js 22 or later (`engines.node: ">=22"`). Node 20 reached end-of-life on 2026-04-30; CI verifies Node 22 and 24.
- Configuration validation rejects values it used to let through. `NaN` was accepted everywhere a number is checked (`NaN <= 0` is false), so `timeout: { attemptMs: NaN }` silently meant no timeout; every numeric option now rejects it. `maxRetries` must be an integer, and the global `maxQueueSize` a non-negative integer. `circuitBreaker` config (client and partition level) is validated for the first time: `failureThreshold`, `halfOpenMaxAttempts` and `window.minimumRequests` must be positive integers, `resetTimeoutMs` and `window.sizeMs` positive and finite, `window.failureRatePercent` in (0, 100], and `isFailure` a function. `halfOpenMaxAttempts: 0` used to keep a tripped circuit open forever. `baseUrl` must be an absolute URL.
- A relative URL with no `baseUrl` resolves the ticket with `ConfigurationError` instead of a `NetworkError`. It's a caller mistake, not a network fault.
- Project renamed `relay` → `vereda`; the public base error class renamed
  `RelayError` → `RequestError` (subclass names — `NetworkError`,
  `TimeoutError`, `ValidationError`, `CancelledError`,
  `MaxRetriesExceededError` — were unchanged) (#9).
- `RetryConfig.maxAttempts` → `maxRetries`, and it now counts **retries
  only**: `maxRetries: 3` means 1 first attempt + 3 retries = 4 total server
  hits, whereas `maxAttempts: 3` previously meant 3 total hits.
  `MaxRetriesExceededError.attempts` likewise now reports total server hits
  rather than the retry count, and the `retry` lifecycle event's attempt
  index is zero-based (0 = first retry) (#27).
- Default retry policy is now conservative instead of broad: only
  `network` / `timeout` / `retryable_status` errors on idempotent methods are
  retried by default; non-idempotent methods (e.g. `POST`) need explicit
  `retry.idempotent: true` or an `Idempotency-Key` header to opt in (#33).
- `TriggerConfig` and `queueOnStatus` replaced by `TimeoutConfig`
  (`timeout.attemptMs`, `timeout.totalMs`) and `retry.retryOnStatus`
  (default `[408, 425, 429, 500, 502, 503, 504]`) (#32).
- Error identity checks should use `instanceof` — `result.error.constructor
  .name === "ValidationError"`-style checks are no longer the supported
  pattern now that errors carry a real class hierarchy (#27).
- Ticket internal state mutators are no longer reachable from consumer code
  (there is no public `ticket._markDone()` etc.); only the client that
  created a ticket can settle it — consumers only ever see the public
  `Ticket` API (#27).
- Development tooling moved from npm to Bun (`bun.lock` is the lockfile) and
  from ESLint/Prettier to Biome; none of this affects how the package is
  consumed, only how it's built and linted.
- Requests without `parse` are typed `Ticket<undefined>`. `client.get<User>(url)` used to type `result.data` as `User` while it was `undefined` at runtime; passing a type argument without `parse` is now a type error. Each method is overloaded: with `parse` it returns `Ticket<T>`, without it `Ticket<undefined>`, and `request()` also accepts a `parse` that may be `undefined` (`Ticket<T | undefined>`). New exported types: `ParsedRequestOptions<T>`, `UnparsedRequestOptions`.
- Removed from the public API: `createTicket` and `TicketController` (anyone holding them could settle a client's tickets), the `HttpClient#json()` method (use the standalone `json()` export), and `RequestOptions.url`, which was never read.
- `ClientConfig.timeout.attemptMs` is now required. Every other default in the library fails safe when omitted; an omitted per-attempt timeout previously meant "unbounded." Pass `Infinity` explicitly to opt out of a cap. Partition- and request-level `timeout` remain optional and inherit the client-level default (#60).
- Each error class now types `kind` as its literal (`HttpError["kind"]` is `"http"`, not `string`), so `switch (error.kind)` on an `AppError` narrows to the matching class and can be checked for exhaustiveness. A bare `RequestError` is no longer assignable to `AppError`, and comparing `kind` against a string outside the closed set is now a type error. At runtime, an unexpected `RequestError` that isn't one of the library's classes is wrapped in a `NetworkError` (original as `cause`) instead of passing through.
- A non-retryable error (e.g. `HttpError`, `ValidationError`) on the *final* retry attempt is no longer wrapped in `MaxRetriesExceededError`. Every other attempt already resolved this way — a terminal error surfaces raw as soon as it happens — but the last attempt fell through to the retries-exhausted path unconditionally. It now resolves with the raw underlying error, same as a terminal error anywhere else in the loop. Only an exhaustion of genuinely retryable errors still resolves with `MaxRetriesExceededError`. Code that pattern-matches on `result.error` at the end of a retry sequence may need to also handle the raw error type it previously only saw wrapped.
- Bodies of unparsed success responses and `HttpError` responses are now bounded by the attempt's remaining `attemptMs` (capped at `totalMs`), measured from attempt start. Previously, once the ticket resolved with the raw `Response`, its body had no deadline at all — a server that sent headers then stalled the body could hang `raw.json()`/`error.response.text()` forever, past both timeouts. Whichever bound fires rejects that read with Vereda's own `TimeoutError` (`attemptMs` fired first, or was the only bound) or `DeadlineExceededError` (`totalMs` fired first) — not a bare `DOMException` — so it's `instanceof` the right class, `.kind`-narrowable, and its `.url` is redacted like any other error the client builds. Streaming or reading a body later than that needs `timeout: { attemptMs: Infinity }` (and no `totalMs`) on that request, or your own `AbortSignal`.
- Request-level `timeout`/`retry` options are now validated the same as client config. A previously-silently-accepted invalid value (e.g. `timeout: { attemptMs: -5 }`) now resolves the ticket with `ConfigurationError` instead of running the request with broken or undefined behavior.

### Changed

- Relative imports in the source now use `.ts` extensions (`rewriteRelativeImportExtensions` turns them into `.js` in the build). The published JavaScript is unchanged; emitted `.d.ts` files reference `./x.ts`, which TypeScript resolves to `./x.d.ts`.
- A response body that isn't valid JSON (when `parse` is set) now resolves with a `ValidationError` and is never retried. Previously it was a `NetworkError`, so it was retried against a server that would give the same answer. A body stream that fails mid-read is still a retriable `NetworkError`.
- `redactQuery` (default `true`) now also redacts userinfo credentials (`https://user:pass@host` → `https://[redacted]@host`) and the URLs embedded in `TimeoutError`/`DeadlineExceededError` (`message` and `url`). The bundled `requestLogger()` middleware now redacts by default.
- The circuit breaker now counts errors it doesn't classify as failures (e.g. a 404 or a `ValidationError`) as successes: the host answered. A half-open trial answered with a 404 closes the circuit; while closed, such a response resets the consecutive-failure count and counts as a non-failure in the rolling window. Previously these responses were ignored entirely.

### Removed

- The `prepare` script (`tsc && husky`) that compiled `dist/` on install is
  gone. Pre-1.0, this package was installed straight from a GitHub ref and
  `prepare` was load-bearing for that. As of 1.0.0 the package ships a
  prebuilt `dist/` on npm, so nothing compiles on install; the local
  git-hook setup for contributors moved to a one-time `npx husky` (see
  CONTRIBUTING.md).
- Top-level `main` / `types` fields, superseded by the `exports` map (`.`,
  `./middleware`, `./zod`), which has been the actual entry-point source of
  truth since before 1.0.0.

### Fixed

- **With `baseUrl` set, retries never reached the server.** Every retry fetched the caller's bare relative path (e.g. `/users`) instead of `baseUrl` + path, failed on the client with a `NetworkError`, and burned the full retry budget.
- A partition-level `timeout.totalMs` was ignored unless the partition was named explicitly with `options.partition`, so it never applied to the default host-derived partitions.
- A first attempt cut off by `timeout.totalMs` emitted a `cancelled` lifecycle event while the ticket resolved with `DeadlineExceededError`. It now emits `failure`, as the retry path already did.
- The query-redacted URL leaked unredacted into the `failure` event when the retry policy or `retryWhen` rejected a first-attempt error.
- `close({ drain: true })` with a missing or non-positive `timeoutMs` marked the client closed before throwing, so a corrected second `close()` returned immediately without draining. It now rejects and leaves the client open. A `close()` called while a drain is in progress now waits for that drain instead of resolving immediately.
- A ticket cancelled (or past its `totalMs`) while waiting for a partition slot or a global concurrency permit kept its place in the queue until it reached the front, so live requests could get a `QueueFullError`. Waiters now leave the queue as soon as their ticket aborts.
- The circuit breaker could get stuck half-open indefinitely. A half-open trial that ended in a non-failure error (e.g. a 404 or a failed `parse`), was cancelled, hit its deadline, was vetoed by `retryWhen`, or got a `QueueFullError` never gave its trial slot back, so every later request to that partition failed with `CircuitOpenError` until the partition sat idle for 60s. Also, a request admitted while the circuit was closed that finished during a later half-open period could close the circuit on behalf of trials still in flight.
- A `ticket.on("done" | "update" | "error")` listener that threw left `ticket.toPromise()` unresolved forever, and stopped the remaining listeners from running.
- A client lifecycle listener or `metrics` sink that threw turned a successful request into a `NetworkError` failure (and emitted a spurious `failure` event). Listener and sink errors are now isolated from the request and rethrown on a microtask.
- `Bulkhead.run()` leaked a phantom running-slot when the global semaphore rejected with `QueueFullError` (the global concurrency cap was saturated mid-retry), permanently degrading that partition's effective concurrency by one per occurrence (#72).
- A `QueueFullError` mid-retry discarded the `queuedMs` already accumulated by retries that had run before it, undercounting exactly the signal `queuedMs` exists to report (#72).
- `cancel()`ing a ticket while it was mid-retry and about to hit a `QueueFullError`, or while a first attempt's global-permit acquire was still pending, could still emit a spurious `failure` event afterward — violating "exactly one of success/failure/cancelled per ticket" (#77).
- `Bulkhead.run()` released a task's semaphore permit *after* draining the next queued waiter instead of before, so that waiter's own `semaphore.acquire()` could spuriously reject with `QueueFullError` even though a permit was about to free (#77).
- A `QueueFullError` from a saturated global semaphore during a retry reported one more `attempts` than actually ran (the rejected call never dispatched the request), and dropped whatever time that same attempt had already spent waiting in its own partition's queue before hitting the global cap (#77).
- A user-supplied `retryWhen` was consulted twice for the first failed attempt — once client-side before queuing, once again at the top of the retry loop — so it saw attempt `0` twice and could veto or count it redundantly. `retryWhen` is now called exactly once per failed attempt that could still be retried.
- With `maxRetries: 0`, `retryWhen` was still consulted once for the first (and only) attempt's failure, even though no retry was possible. It's now never called — the failed attempt resolves with its own error, unwrapped, as before.
- The circuit breaker counted an attempt that never reached the host (a body factory that threw before the request was dispatched) as a success — it reset the consecutive-failure count, counted as a non-failure in the rolling window, and could close a half-open trial, all without the host ever being contacted. Such an outcome is now ignored entirely: no change to the failure count, the rolling window, or open/closed state, and in half-open it frees the trial slot without deciding it.
- A user-supplied circuit-breaker `isFailure` was called twice for every attempt it classified as a failure — once to classify the outcome, then again while recording it — so a classifier that counts, logs, or samples saw each real failure twice. It's now called exactly once per reported outcome.

[Unreleased]: https://github.com/riosgabriel/vereda/compare/v1.1.0...HEAD
[1.1.0]: https://github.com/riosgabriel/vereda/compare/v1.0.2...v1.1.0
[1.0.2]: https://github.com/riosgabriel/vereda/compare/v1.0.1...v1.0.2
[1.0.1]: https://github.com/riosgabriel/vereda/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/riosgabriel/vereda/releases/tag/v1.0.0
