Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/olive-eagles-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"cnosdb-client": minor
---

Add an opt-in `retry` policy. Retries stay off unless configured, so the default remains one call, one request; enabling them retries `ping`, `query`, and `queryTable` on timeouts, connection failures, HTTP 429, and 5xx other than 501. Writes are retried only with `retryWrites`, and `execute` is never retried, because the client cannot tell whether a failed attempt took effect.

Backoff doubles from `backoff.initialMs` up to `backoff.maxMs` with full jitter by default, a `Retry-After` header overrides the computed delay within that cap, and an `AbortSignal` ends the sequence immediately including mid-backoff.

`timeoutMs` keeps its existing meaning as the budget for a single attempt rather than becoming a deadline across the sequence; `retry.maxElapsedMs` bounds the total. The reasoning, and the amendment to ADR-0006, are recorded in ADR-0009.
58 changes: 56 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ A small, dependency-free TypeScript client for the CnosDB HTTP API.
- Deterministic Line Protocol serialization from plain JavaScript objects.
- Typed errors for authentication, rate limiting, timeouts, network failures, and malformed responses.
- Per-client and per-request timeouts, plus `AbortSignal` cancellation.
- Opt-in retries with jittered backoff, off by default and never retrying a write unless you say so.
- Zero runtime dependencies; built on the platform `fetch`.
- Ships ESM, CommonJS, and strict TypeScript declarations with source maps.
- No import-time side effects, so unused exports tree-shake away.
Expand Down Expand Up @@ -81,7 +82,8 @@ console.log(rows);
| `password` | `string` | — | Basic-auth password; may be empty. |
| `database` | `string` | `"public"` | Default database. |
| `tenant` | `string` | `"cnosdb"` | Default tenant. |
| `timeoutMs` | `number` | `10000` | Default request timeout. |
| `timeoutMs` | `number` | `10000` | Timeout for a single attempt. |
| `retry` | `RetryOptions` | — | Retry policy; off unless supplied. |
| `precision` | `"ms" \| "us" \| "ns"` | `"ms"` | Default write precision. |
| `compression` | `"none" \| "gzip"` | `"none"` | Compression for write payloads. |
| `headers` | `Record<string,string>` | — | Extra headers sent with every request. |
Expand Down Expand Up @@ -332,6 +334,57 @@ await pending; // rejects with CnosDBRequestError, code "ABORT_ERR"

Override the timeout per request with `timeoutMs`.

## Retries

Retries are off by default: one call, one request. A client that retries a
write silently can duplicate points and corrupt aggregates long before anyone
notices, so nothing is retried until you ask.

```ts
const client = new CnosDBClient({
url: "http://localhost:8902",
retry: {
attempts: 3,
backoff: { initialMs: 100, maxMs: 2_000, jitter: true },
retryWrites: false,
},
});
```

| Option | Default | Description |
| ------------------- | ------- | ------------------------------------------------- |
| `attempts` | — | Total attempts including the first; `1` disables. |
| `backoff.initialMs` | `100` | First delay. |
| `backoff.maxMs` | `2000` | Cap on any single delay. |
| `backoff.jitter` | `true` | Spread the delay randomly across the interval. |
| `retryWrites` | `false` | Also retry writes. |
| `maxElapsedMs` | — | Wall-clock ceiling for the whole sequence. |

**What gets retried.** `ping`, `query`, and `queryTable` are retried, because
they are the methods that return rows. Writes are retried only with
`retryWrites`. `execute` is never retried, whatever you configure: it exists for
statements whose point is their effect, and the client cannot tell whether a
failed attempt took hold before the connection dropped. If you send an `INSERT`
through `query` with retries on, it will be retried — send it through `execute`.

**Which failures.** Timeouts, connection failures, HTTP 429, and 5xx other than 501. Rejected credentials, malformed SQL, an oversized payload, and a caller
abort are final, because the server will decide them the same way next time.

**Timing.** `timeoutMs` is the budget for one attempt, not for the sequence, so
enabling retries does not shrink the time any single attempt gets. Use
`maxElapsedMs` for a bound on the total. Backoff doubles from `initialMs` up to
`maxMs`, and with jitter the actual wait is a random point below that, so a
fleet of clients that failed together does not come back in lockstep. A
`Retry-After` header wins over the computed delay, capped by `maxMs`.

An `AbortSignal` ends the sequence immediately, including during a backoff.

`retryWrites` is worth a moment's thought before enabling. CnosDB does not
deduplicate, so a retried write whose first attempt actually landed writes the
points twice. That is harmless when a repeat overwrites the same series and
timestamp, and quietly wrong otherwise. The reasoning is recorded in
[ADR-0009](docs/adr/0009-opt-in-retry-policy.md).

## Error handling

```ts
Expand Down Expand Up @@ -403,7 +456,8 @@ splitPoints(points: readonly Point[], options: SplitOptions): Generator<string>
```

Exported types: `CnosDBClientOptions`, `RequestOptions`, `QueryOptions`,
`WriteOptions`, `PingResult`, `Point`, `PointFieldValue`, `TimePrecision`,
`WriteOptions`, `RetryOptions`, `BackoffOptions`, `SplitOptions`, `PingResult`,
`QueryTable`, `Point`, `PointFieldValue`, `TimePrecision`, `Compression`,
`FetchLike`, and `CnosDBErrorOptions`.

## Compatibility
Expand Down
5 changes: 4 additions & 1 deletion docs/adr/0006-disable-automatic-retries-in-v0.1.0.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0006: Disable automatic retries in v0.1.0

**Status:** Accepted
**Status:** Accepted, amended by [ADR-0009](0009-opt-in-retry-policy.md)
**Date:** 2026-07-24

## Context
Expand Down Expand Up @@ -42,6 +42,9 @@ An opt-in retry policy is a candidate for a later version
([ROADMAP.md](../../ROADMAP.md)). If added, it must be off by default and must
never retry a write unless the caller explicitly accepts duplication risk.

> [ADR-0009](0009-opt-in-retry-policy.md) added that policy under both
> conditions. The default is still one call, one request.

## Consequences

**Good:**
Expand Down
103 changes: 103 additions & 0 deletions docs/adr/0009-opt-in-retry-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# ADR-0009: Opt-in retry policy, with a per-attempt timeout

**Status:** Accepted
**Date:** 2026-07-25
**Amends:** [ADR-0006](0006-disable-automatic-retries-in-v0.1.0.md)

## Context

ADR-0006 shipped v0.1.0 with no retries, because a client that silently retries
a write can duplicate points and corrupt aggregates without the caller ever
learning that it happened. That reasoning still holds, and the default is not
changing.

What has changed is the evidence about the cost. Every caller running against a
real network ends up writing the same backoff loop, and the loop they write
cannot see what the client already knows: the `Retry-After` header on a 429,
whether a connection ever opened, and whether a timeout was the one the caller
configured or one the server imposed. Rebuilding that outside the client means
reconstructing information the client threw away.

ADR-0006 left two conditions on any future policy: off by default, and never
retry a write unless the caller explicitly accepts the duplication risk. It also
noted the interface design as the real work, and named one question it did not
answer: what `timeoutMs` means once there is more than one attempt.

## Decision

Add an opt-in `retry` option on the client. Absent, behaviour is exactly what
v0.1.0 shipped: one call, one request.

**`timeoutMs` stays the budget for a single attempt.** Each attempt gets the
full amount. A caller who needs a ceiling on the whole sequence sets
`retry.maxElapsedMs`, which stops the client from starting an attempt it knows
cannot finish inside the budget.

The alternative — reinterpreting `timeoutMs` as a deadline across all attempts —
was rejected. It would silently redefine an existing option, so the same code
would behave differently after an upgrade, and the redefinition would be
invisible until the day a request was slow. It would also make each attempt's
budget shrink as attempts accumulate, so the last attempt, the one made under
the worst conditions, would be given the least time to succeed. That is exactly
backwards. A per-attempt budget also means "a single request must not hang for
more than X", which is what a caller writing `timeoutMs` is actually saying.

**What is retried.** `ping`, `query`, and `queryTable` are retried. Writes are
retried only with `retry.retryWrites`, which is the explicit acceptance of
duplication risk that ADR-0006 required. `execute` is never retried, whatever
the configuration.

ADR-0006 rejected "retry reads, never writes" on the grounds that `POST
/api/v1/sql` carries both `SELECT` and `INSERT` and the client cannot tell them
apart without parsing SQL. That is still true, and this ADR does not solve it by
parsing. It solves it by using the method the caller chose as the declaration:
`query` and `queryTable` return rows and are treated as reads, `execute` exists
for statements whose point is their effect and is treated as unsafe to repeat.
A caller who sends an `INSERT` through `query` has miscategorised it, and the
documentation says so plainly at both methods.

**Which failures are retried.** Timeouts, network failures, 429, and 5xx other
than 501. Everything else — rejected credentials, malformed SQL, a payload too
large, a caller abort — is final, because the server will decide it the same way
next time.

**Backoff.** Exponential from `initialMs`, capped at `maxMs`, with full jitter
on by default so a fleet of clients that failed together does not return in
lockstep. A `Retry-After` from the server overrides the computed delay, since
the server knows when it will be ready, but is still capped by `maxMs` so a
mistaken or hostile header cannot park the caller indefinitely.

**Cancellation.** An `AbortSignal` ends the sequence immediately, including
during a backoff sleep. A caller who cancels does not wait out a delay first.

## Consequences

**Good:**

- The duplicate-write failure mode ADR-0006 exists to prevent stays off by default and behind an explicit flag when enabled.
- Callers stop rewriting the same loop, and the loop they no longer write is the one that could not see `Retry-After`.
- `timeoutMs` keeps its meaning, so upgrading changes nothing for code that does not set `retry`.
- The retry decision lives next to the typed errors that classify failures, which is where the information already is.

**Costs:**

- `query` being retryable is a judgement about intent, not a fact the client can verify. A caller who sends mutating SQL through `query` and enables retries can duplicate work. Documented at the method, but documentation is weaker than a guarantee.
- More configuration surface, and more behaviour that must be explained before someone can predict what a failing call will do.
- A retried request occupies a connection longer than a failed one, so an overloaded server sees load fall more slowly than it would without retries, jitter notwithstanding.

## Alternatives considered

**Leave retries to the caller.** The status quo, and still workable. Rejected
because the argument for building it was never that the loop is hard to write;
it is that the caller cannot see what the client discarded.

**Retry `execute` too.** Rejected. `execute` exists precisely for statements
that change something, and the client cannot tell whether a failed attempt took
effect before the connection dropped.

**Infer idempotency by parsing SQL.** Rejected for the same reason ADR-0006
rejected it: parsing SQL to guess intent is out of scope and would be wrong at
exactly the moments it mattered.

**Retry on any 5xx including 501.** Rejected. 501 means the server will never
implement the endpoint, so waiting cannot change the answer.
22 changes: 12 additions & 10 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@ constraint and an accident.

## Index

| ADR | Title | Status |
| --------------------------------------------------- | ----------------------------------- | -------- |
| [0001](0001-use-cnosdb-http-api.md) | Use the CnosDB HTTP API | Accepted |
| [0002](0002-use-native-fetch.md) | Use native fetch | Accepted |
| [0003](0003-publish-esm-and-commonjs.md) | Publish ESM and CommonJS | Accepted |
| [0004](0004-use-changesets.md) | Use Changesets | Accepted |
| [0005](0005-use-issue-first-github-flow.md) | Use issue-first GitHub Flow | Accepted |
| [0006](0006-disable-automatic-retries-in-v0.1.0.md) | Disable automatic retries in v0.1.0 | Accepted |
| [0007](0007-use-unofficial-independent-branding.md) | Use unofficial independent branding | Accepted |
| [0008](0008-group-source-into-domain-folders.md) | Group source into domain folders | Accepted |
| ADR | Title | Status |
| --------------------------------------------------- | ----------------------------------- | -------------------------------------------------------- |
| [0001](0001-use-cnosdb-http-api.md) | Use the CnosDB HTTP API | Accepted |
| [0002](0002-use-native-fetch.md) | Use native fetch | Accepted |
| [0003](0003-publish-esm-and-commonjs.md) | Publish ESM and CommonJS | Accepted |
| [0004](0004-use-changesets.md) | Use Changesets | Accepted |
| [0005](0005-use-issue-first-github-flow.md) | Use issue-first GitHub Flow | Accepted |
| [0006](0006-disable-automatic-retries-in-v0.1.0.md) | Disable automatic retries in v0.1.0 | Accepted, amended by [0009](0009-opt-in-retry-policy.md) |
| [0007](0007-use-unofficial-independent-branding.md) | Use unofficial independent branding | Accepted |
| [0008](0008-group-source-into-domain-folders.md) | Group source into domain folders | Accepted |
| [0009](0009-opt-in-retry-policy.md) | Opt-in retry policy | Accepted |

## When to write one

Expand All @@ -29,6 +30,7 @@ legal positioning. Do not write one for ordinary implementation choices.
```text
Title
Status Proposed | Accepted | Superseded by ADR-XXXX | Deprecated
Amends ADR-XXXX, when this narrows or extends an earlier decision
Date
Context The forces at play, and what makes this hard.
Decision What we are doing, stated plainly.
Expand Down
16 changes: 11 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Within each folder:
| `types/` | `common`, `client-options`, `request-options`, `point`, `ping` |
| `errors/` | `base`, `http-status`, `transport`, `response`, `from-status` |
| `line-protocol/` | `escape`, `field`, `timestamp`, `serialize` |
| `http/` | `url`, `auth`, `body`, `guards`, `transport` |
| `http/` | `url`, `auth`, `body`, `guards`, `retry`, `transport` |
| `client/` | `defaults`, `validate`, `controls`, `client` |

### The barrel rule
Expand Down Expand Up @@ -163,16 +163,22 @@ Each of these has an ADR in [adr/](adr/):
- Dual ESM and CommonJS output ([0003](adr/0003-publish-esm-and-commonjs.md)).
- Changesets for releases ([0004](adr/0004-use-changesets.md)).
- Issue-first GitHub Flow ([0005](adr/0005-use-issue-first-github-flow.md)).
- No automatic retries ([0006](adr/0006-disable-automatic-retries-in-v0.1.0.md)).
- No automatic retries by default ([0006](adr/0006-disable-automatic-retries-in-v0.1.0.md), amended by [0009](adr/0009-opt-in-retry-policy.md)).
- Unofficial, independent branding ([0007](adr/0007-use-unofficial-independent-branding.md)).
- Opt-in retry policy with a per-attempt timeout ([0009](adr/0009-opt-in-retry-policy.md)).

### Why no retries
### Why retries are off by default

A retry is a correctness decision, not a convenience. `POST /api/v1/write` is
not safely idempotent from the client's point of view: a timeout may mean the
write never landed, or that it landed and the response was lost. Retrying
silently duplicates data in the second case. So v0.1.0 retries nothing and
instead exposes typed errors precise enough for the caller to decide.
silently duplicates data in the second case.

So the client retries nothing unless asked. A caller who sets `retry` gets
reads retried; writes stay excluded until `retryWrites` says otherwise. The
decision of what counts as retryable lives in `http/retry.ts`, next to the
typed errors that classify each failure, and the transport consults it only for
requests the client itself has marked repeatable.

### Dependency policy

Expand Down
Loading