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
7 changes: 6 additions & 1 deletion docs/sdk-documentation/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ export async function call(): Promise<void> {
the two apart at the point of abort — which is exactly how a transport decides which of the two
errors to raise.

**Neither narrowing changes when a retry pillar is installed.** What `retryStep` throws once it gives
up is the final attempt's own error, unwrapped, so the two `instanceof` checks above read the same at
`maxAttempts: 1` and at `maxAttempts: 3`. The earlier attempts ride beside it and are read with
`retryAttempts(error)` — see [`pipelines.md`](./pipelines.md#the-four-shipped-pillars).

## Narrowing helpers

Three predicates exist for the cases where `instanceof` on a union is tedious:
Expand Down Expand Up @@ -185,7 +190,7 @@ Two more joined them on the same date, both from `XCUT-8`'s "never fabricate a s
| Error | Raised by |
|---|---|
| `HttpStatusValidationError` | `new HttpStatusError(status, …)` when `status` is not an integer in 400–599. The constructor validated nothing before, so a consumer could build an `HttpStatusError` claiming a `200`. `toHttpError` is the total form — it returns `null` instead of throwing |
| `RetryDiscardedResponseError` | the retry engine's suppressed trail, for a response it discarded whose status is outside 400–599. Reachable only if you widen `RetrySettings.retryableStatuses` to include a non-error code; the trail previously said `HttpStatusError` for it, which claimed an HTTP failure that had not happened |
| `RetryDiscardedResponseError` | the retry engine's prior-attempt trail (`retryAttempts()`), for a response it discarded whose status is outside 400–599. Reachable only if you widen `RetrySettings.retryableStatuses` to include a non-error code; the trail previously said `HttpStatusError` for it, which claimed an HTTP failure that had not happened |

All of them descend from `DexpaceError`, so the broad catch works too.

Expand Down
46 changes: 44 additions & 2 deletions docs/sdk-documentation/pipelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,50 @@ All three carry the same `InstrumentationBundle`, so trace and span identity sur
| Auth | `authStep(settings)` | `credentials`, `tiers`, `challengeHook`, `bearerMarginMs` — see [`auth.md`](./auth.md) |
| Logging | `loggingStep(settings?)` | `granularity`, `severity`, `previewSizeBytes`, `droppedHeaderPolicy`, `logger`, `meter`, `tracerFactory` |

Retry and redirect are worth two notes each, because both surprise people:

Retry and redirect are worth a few notes each, because both surprise people:

- **What retry throws is the last attempt's own error.** The class you catch does not depend on how
many attempts ran: a refused connection is a `TransportFailureError` whether `maxAttempts` was 1 or
3, and an abort that lands during a backoff wait is a `CancellationError` (`XCUT-1`). The earlier
attempts are not thrown away — `retryAttempts(caught)` returns them, oldest first, with the error
you passed in excluded from its own trail (`RETRY-34`):

```typescript
import {
retryAttempts,
TransportFailureError,
type Request,
type Runtime,
} from '@dexpace/core';

declare const runtime: Runtime;
declare const request: Request;

export async function send(): Promise<void> {
try {
await runtime.send(request);
} catch (error) {
if (error instanceof TransportFailureError) {
for (const prior of retryAttempts(error)) {
console.error('an earlier attempt failed:', prior);
}
}
throw error;
}
}
```

One entry per attempt that failed *before* the one you caught — which is not an attempt count, so
resist writing `length + 1`. The surfaced error is an attempt's own only when it came from one, and
sometimes it did not: a cancellation or timeout the engine observes between attempts is synthesized
at that gate, and so is a failure from stamping the attempt header, which runs before the request
goes out. On those paths the trail already covers every send. Narrowing the catch does not help —
a timeout signal is mapped to `TransportFailureError`, the same class a real send failure raises.

The trail is a side table keyed by the error, not a property on it, so nothing is added to an
object you may not own; an error that never went through a retry loop answers with an empty list.
Until 2026-09-05 the pillar wrapped its terminal failure in a `SuppressedError` instead, which made
the surfaced class a function of the attempt budget.
- **Retry pacing honours the server.** `Retry-After`, `X-RateLimit-Reset` and friends are parsed in a
fixed precedence and win over computed backoff. Every computed delta is clamped to a 365-day
ceiling that `RETRY-18` mandates — so a server that sends `X-RateLimit-Reset` in milliseconds
Expand Down
12 changes: 12 additions & 0 deletions docs/sdk-documentation/write-a-response-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ release failure. **`instanceof SuppressedError` is not a valid test**: the class
project's declared Node floor and a structurally identical stand-in is built there instead. Test the
shape, or read `.error` unconditionally.

**A failed release is the only thing in this SDK that builds one.** Every site that constructs the
pairing — the response chain, the redirect and auth and retry pillars, the serde handlers, the SSE
stream, the paginator — is doing this one job: a `close()` that threw while an error was already in
flight. It is a genuine two-value shape, which is why it fits.

The retry pillar used to build one for a *second* reason, folding N prior attempt errors into a
nested chain of pairs so that what you caught after three attempts was a wrapper rather than the
failure. It no longer does: it surfaces the last attempt's error unwrapped and records the earlier
ones beside it, read back with `retryAttempts()` (see [`pipelines.md`](./pipelines.md#the-four-shipped-pillars)).
So if you catch a `'SuppressedError'` from this SDK, `.suppressed` is a teardown failure, never an
earlier attempt.

**3. Only payload failures are re-typed.** `SERDE-12`: a malformed body or a shape mismatch becomes a
`DeserializationError` with the original chained; a genuine stream failure propagates untouched,
because re-wrapping it would tell a caller their payload was malformed when their socket dropped.
Expand Down
3 changes: 3 additions & 0 deletions packages/core/etc/core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,9 @@ export type ResponseStep = (response: Response_2) => Promise<Response_2>;
// @public
export const RETRYABLE_STATUSES: ReadonlySet<number>;

// @public
export function retryAttempts(error: unknown): readonly unknown[];

// @public
export class RetryDiscardedResponseError extends DexpaceError {
constructor(status: number, options?: ErrorOptions);
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ export {
ReservedStageError,
} from './pipeline/errors.js';
export {retryStep} from './retry/retry-step.js';
// RETRY-34's read side. The retry pillar surfaces the FINAL attempt's own error, so `instanceof`
// against it does not depend on how many attempts ran; this is how the earlier ones are reached.
// Exported alongside `retryStep` because the two are one contract: nothing else in the barrel can
// tell a caller that the error they caught is the third of three.
export {retryAttempts} from './retry/attempt-trail.js';
// The trail entry for a response the engine discarded whose status is outside 400-599 — reachable
// only through a caller-widened `retryableStatuses`.
export {RetryDiscardedResponseError} from './retry/errors.js';
Expand Down
138 changes: 138 additions & 0 deletions packages/core/src/retry/attempt-trail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: MIT
// packages/core/src/retry/attempt-trail.test.ts
// Exercises: RETRY-34 (the prior-attempt trail rides alongside the surfaced error rather than
// replacing it; the surfaced instance is skipped; a run with no priors leaves no trail behind),
// XCUT-1 (recording a trail never changes the surfaced value's class).
import {describe, expect, test} from 'bun:test';
import {IoError} from '../io/errors.js';
import {CancellationError} from '../seams/transport.js';
import {recordAttempts, retryAttempts} from './attempt-trail.js';

describe('retryAttempts -- the read side', () => {
test('returns an empty list for an error that never went through the engine', () => {
expect(retryAttempts(new IoError('never retried'))).toEqual([]);
});

test('returns an empty list for a primitive, which cannot carry a trail at all', () => {
// RETRY-34's trail is keyed by identity, so a string, a number or a symbol throw passes
// through the engine unchanged and unannotated rather than being wrapped to make room.
expect(retryAttempts('a bare string throw')).toEqual([]);
expect(retryAttempts(42)).toEqual([]);
expect(retryAttempts(Symbol('thrown'))).toEqual([]);
expect(retryAttempts(null)).toEqual([]);
expect(retryAttempts(undefined)).toEqual([]);
});

test('returns the recorded attempts oldest first', () => {
const first = new IoError('first');
const second = new IoError('second');
const surfaced = new IoError('third');

recordAttempts(surfaced, [first, second]);

expect(retryAttempts(surfaced)).toEqual([first, second]);
});

test('hands back a frozen list, so one caller cannot edit a trail another caller holds', () => {
const surfaced = new IoError('surfaced');
recordAttempts(surfaced, [new IoError('prior')]);

const attempts = retryAttempts(surfaced);

expect(Object.isFrozen(attempts)).toBe(true);
});

test('does not read a trail off the cause chain -- only off the instance itself', () => {
const inner = new IoError('inner');
recordAttempts(inner, [new IoError('prior')]);
const outer = new IoError('outer', {cause: inner});

expect(retryAttempts(outer)).toEqual([]);
});
});

describe('recordAttempts -- the write side', () => {
test('leaves the class and identity of the error untouched (XCUT-1)', () => {
const surfaced = new CancellationError('operation cancelled');

recordAttempts(surfaced, [new IoError('prior')]);

expect(surfaced).toBeInstanceOf(CancellationError);
expect(surfaced.name).toBe('CancellationError');
});

test('adds no own property, so a JSON or structured-clone round trip is unchanged', () => {
const surfaced = new IoError('surfaced');
const before = Object.getOwnPropertyNames(surfaced).sort();

recordAttempts(surfaced, [new IoError('prior')]);

expect(Object.getOwnPropertyNames(surfaced).sort()).toEqual(before);
});

test('copies the trail, so a later push by the engine cannot mutate a published list', () => {
const surfaced = new IoError('surfaced');
const trail: unknown[] = [new IoError('prior')];

recordAttempts(surfaced, trail);
trail.push(new IoError('added afterwards'));

expect(retryAttempts(surfaced)).toHaveLength(1);
});
});

describe('recordAttempts -- the values it can and cannot key on', () => {
test('is a no-op on a frozen error rather than throwing', () => {
// The reason the trail is a side table and not an own property: a foreign error may be frozen
// or non-extensible, and `defineProperty` inside the failure path of the engine would then replace
// the failure the caller cares about with a TypeError.
const surfaced = Object.freeze(new IoError('frozen by its author'));
const prior = new IoError('prior');

expect(() => {
recordAttempts(surfaced, [prior]);
}).not.toThrow();
expect(retryAttempts(surfaced)).toEqual([prior]);
});

test('is a no-op for a primitive surfaced value', () => {
expect(() => {
recordAttempts('a bare string throw', [new IoError('prior')]);
}).not.toThrow();
expect(retryAttempts('a bare string throw')).toEqual([]);
});

test('records against a thrown function, which is an object for these purposes', () => {
const surfaced = (): void => undefined;
const prior = new IoError('prior');

recordAttempts(surfaced, [prior]);

expect(retryAttempts(surfaced)).toEqual([prior]);
});
});

describe('recordAttempts -- a reused error instance', () => {
test('an empty trail clears any entry a previous run left on a reused instance', () => {
// Error singletons are ordinary in fakes and in transports that reuse one instance. The RETRY-34
// clause "on eventual success the prior trail MUST be discarded" is worth nothing if the NEXT run
// to surface that same instance still reports the old one.
const reused = new IoError('reused across runs');
recordAttempts(reused, [new IoError('from the first run')]);

recordAttempts(reused, []);

expect(retryAttempts(reused)).toEqual([]);
});

test('the latest recording wins for a reused instance', () => {
const reused = new IoError('reused across runs');
const older = new IoError('from the first run');
const newer = new IoError('from the second run');

recordAttempts(reused, [older]);
recordAttempts(reused, [newer]);

expect(retryAttempts(reused)).toEqual([newer]);
});
});
101 changes: 101 additions & 0 deletions packages/core/src/retry/attempt-trail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MIT
// packages/core/src/retry/attempt-trail.ts

/**
* RETRY-34's prior-attempt trail, held in a side table keyed by the surfaced throwable rather than
* written onto it.
*
* A `WeakMap` because the key is the error a caller is about to catch: the entry has to disappear
* when that error does, and a `Map` here would pin every failed request's error graph -- including
* whatever the buffered `HttpStatusError` bodies hold -- for the life of the process.
*/
const attemptTrails = new WeakMap<object, readonly unknown[]>();

/** One shared frozen empty list, so the common "no trail" answer allocates nothing. */
const NO_ATTEMPTS: readonly unknown[] = Object.freeze([]);

/**
* The subset of throwables a `WeakMap` can key on. Objects and functions qualify; primitives do
* not, and a registered symbol throws when used as a weak key, so symbols are excluded outright
* rather than probed.
*/
function trailKey(value: unknown): object | undefined {
if (typeof value === 'function') return value;
return typeof value === 'object' && value !== null ? value : undefined;
}

/**
* Records the errors of the earlier attempts against the error the retry engine is about to
* surface (`RETRY-34`).
*
* Written into a side table instead of onto the error for three reasons the engine cannot rule out
* about a throwable it did not construct: it may be frozen or otherwise non-extensible, so a
* `defineProperty` in the failure path would itself throw and replace the failure the caller cares
* about; it may be a primitive, which can carry nothing at all; and `suppressed` already means
* "the one secondary" on `SuppressedErrorLike`, so reusing the name would collide with
* `RECOV-12`'s pairing.
*
* `attempts` MUST already have the surfaced instance filtered out -- `RETRY-34`'s skip-self clause,
* applied by the engine's `attachTrail` (`engine.ts`), which is the one caller. An empty
* `attempts` DELETES any entry a previous run left, so a transport that reuses a single error
* instance across calls reports the trail of the run that just surfaced it rather than a stale one.
*
* The list is copied and frozen, so the engine's own mutable `trail` array cannot be observed
* growing after the fact.
*
* @param error - the throwable the engine is surfacing; a primitive is silently ignored.
* @param attempts - the earlier attempts' errors, oldest first, surfaced instance excluded.
*
* @internal
*/
export function recordAttempts(
error: unknown,
attempts: readonly unknown[],
): void {
const key = trailKey(error);
if (key === undefined) return;
if (attempts.length === 0) {
attemptTrails.delete(key);
return;
}
attemptTrails.set(key, Object.freeze([...attempts]));
}

/**
* The errors of the attempts that came before the one you caught.
*
* The retry pillar surfaces the **final** attempt's own error, unwrapped: `instanceof` against it
* answers the same for one attempt as for ten, and a cancellation that ended a backoff wait arrives
* as `CancellationError` rather than as something carrying one. The earlier attempts are not
* discarded — they are recorded here, one entry per attempt that failed BEFORE the error you caught.
* A worked example is in `docs/sdk-documentation/pipelines.md`.
*
* **That is not an attempt count, and `length + 1` is not one either.** The arithmetic holds only
* when the surfaced error is itself an attempt's, and on three reachable paths it is not: a
* cancellation or timeout the engine observes at its `RETRY-32` gate is synthesized there rather
* than raised by a send; a failure from stamping the attempt header is raised before the request
* goes out; and a `Clock.sleep` that rejects for something other than an abort fails after the
* attempt it followed is already in the trail. On each of those the trail already accounts for every
* send, so adding one overstates it. Narrowing the catch does not rescue the sum — `abortToSdkError`
* yields `TransportFailureError` for a timeout signal, so even that class can reach you without a
* send behind it.
*
* Oldest first, and the error you passed in is never a member of its own trail (`RETRY-34`'s
* skip-self clause, which matters because a transport may reuse one error instance across
* attempts). A run that succeeded, a failure that was never retried, and any error this SDK did not
* surface from a retry loop all answer with an empty list — this never throws and never returns
* `undefined`.
*
* The result is frozen, and it is read by identity: an error reached through another error's
* `cause` has its own trail or none, never its wrapper's.
*
* @param error - the throwable a retrying pipeline surfaced; any value, not necessarily an `Error`.
* @returns the earlier attempts' errors, oldest first, or an empty list when there are none.
*
* @public
*/
export function retryAttempts(error: unknown): readonly unknown[] {
const key = trailKey(error);
if (key === undefined) return NO_ATTEMPTS;
return attemptTrails.get(key) ?? NO_ATTEMPTS;
}
Loading
Loading