Skip to content

refactor(exchange): declare retry policy per endpoint with a @retry decorator - #1200

Open
bennycode wants to merge 4 commits into
mainfrom
refactor/trading212-retry-decorator
Open

refactor(exchange): declare retry policy per endpoint with a @retry decorator#1200
bennycode wants to merge 4 commits into
mainfrom
refactor/trading212-retry-decorator

Conversation

@bennycode

@bennycode bennycode commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Why

Both API clients configured retries through axios-retry interceptors that had to reverse-map failed request URLs/config back to policy. Trading212API's getRetryDelay matched URLs via string comparison — a design that already produced one silent bug (strict === missing paginated nextPagePath URLs — see the apology comment it carried), and every new endpoint risked silently falling through to the default delay.

What

@retry method decorator (TC39 standard mode, packages/exchange/src/util/retry.ts): each API method declares its own rate-limit policy right where the endpoint lives — no URL-matching table to keep in sync.

/** @see https://t212public-api-docs.redoc.ly/#operation/accountCash */
@retry({delayMs: 2_000})
async getAccountCash() { ... }
  • Default retryable predicate matches the old axiosRetry.isRetryableError set: network errors, 429, 5xx (as SimplifiedHttpError statuses 0/429/5xx, since simplifyError runs at the interceptor level).
  • Per-endpoint delays carried over 1:1 from the old table.
  • getHistoryOrders now pages through the decorated getHistoryOrdersPage, preserving per-page retry semantics (a decorator on the aggregate would restart pagination) and deleting the duplicated pagination loop.

AlpacaAPI converted too (second commit): Alpaca applies one rate-limit policy across all endpoints, so a single shared retryAlpaca decorator (20 retries, linear 1s backoff) replaces the axios-retry wiring on both axios clients. shouldRetryAlpacaRequest now operates on SimplifiedHttpError instead of raw AxiosError; the PDT (40310100) and short-selling (40310000) exclusions are preserved and covered by the existing test suite (+1 new case).

Toolchain enablement:

  • Dropped vestigial experimentalDecorators/emitDecoratorMetadata from tsconfig.lib.json — no legacy decorators exist in the repo, and the flag forced legacy semantics that break standard-mode decorators.
  • Added a scoped pre-transform plugin to vitest.config.base.ts: oxc (rolldown-vite/Vitest 4) cannot downlevel standard decorators yet, so decorator-bearing files route through esbuild (already present via tsx). Remove once oxc ships support.

Behavior notes

  • Order placement/cancel endpoints keep the old default backoff (attempt × 1s) — unchanged.
  • ECONNABORTED timeouts would now retry where axios-retry excluded them; moot since neither client configures a timeout.
  • axios-retry is fully removed as a dependency (third commit): the retry loop is extracted into a plain withRetry() that the decorator now adapts, and isEarningsDay — the last consumer — uses the function form with its original policy (network or 429, 3 retries, linear 1s backoff) against raw AxiosErrors, since its finnhub client skips simplifyError. Its vi.mock(.axios-retry.) workaround is gone too. (ts-retry-promise in the WebSocket managers is a different domain — left as is.)

Verified

  • Typecheck: all 5 packages
  • Tests: exchange 95, trading-signals 453, trading-strategies 259, messaging 84 — all green
  • tsc emit + runtime smoke test of built dist/ class
  • Next.js docs build (compiles exchange from source via tsconfig paths)
  • eslint clean; tsx demo scripts unaffected (esbuild 0.28 downlevels standard decorators)


type AsyncMethod<This, Args extends unknown[], Result> = (this: This, ...args: Args) => Promise<Result>;

export interface RetryOptions {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract RetryOptions from axios-retry

Comment thread vitest.shared.ts
target: 'es2022',
},
plugins: [

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is weird, find another alternative

}

/** @see https://t212public-api-docs.redoc.ly/#operation/positionByTicker */
@retry()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make delay mandatory so we can see what's the retry delay directly at the function

…ecorator

Replace the axios-retry interceptor in Trading212API with a standard-mode
(TC39) method decorator. The interceptor had to reverse-map failed request
URLs back to per-endpoint delays via string matching — a design that already
produced one silent fallback bug on paginated URLs. Each API method now
declares its own rate-limit delay where the endpoint lives.

- Add retry() method decorator with configurable delay, retry limit, and
  retryable-error predicate (defaults match axiosRetry.isRetryableError:
  network errors, 429, 5xx — as SimplifiedHttpError statuses 0/429/5xx)
- getHistoryOrders now pages through the decorated getHistoryOrdersPage,
  preserving per-page retry and removing duplicated pagination code
- Drop vestigial experimentalDecorators/emitDecoratorMetadata from
  tsconfig.lib.json (no legacy decorators exist; the flag forced legacy
  semantics and broke standard-mode decorators)
- Bridge Vitest 4: oxc cannot downlevel standard decorators yet, so a
  scoped pre-transform plugin routes decorator-bearing files through
  esbuild until oxc ships support
Alpaca applies one rate-limit policy across all endpoints, so a single
shared retryAlpaca decorator replaces the axios-retry wiring on both
axios clients (same policy: 20 retries, linear 1s backoff).

shouldRetryAlpacaRequest now operates on SimplifiedHttpError instead of
raw AxiosError, since the decorator sits above the simplifyError
interceptor. The PDT (40310100) and short-selling (40310000) exclusions
are preserved, and the transient check reuses the now-exported
isTransientHttpError from retry.ts.
Extract the retry loop from the @Retry decorator into a plain withRetry()
function so free functions can share the same policy machinery; the
decorator is now a thin adapter over it.

isEarningsDay was the last axios-retry consumer. Its finnhub client skips
simplifyError, so its predicate checks the raw AxiosError (network error
or 429 — same policy as before, 3 retries, linear 1s backoff). The
axios-retry module mock in its test is gone since the module no longer
loads retry wiring at import time.
@bennycode
bennycode force-pushed the refactor/trading212-retry-decorator branch from 1e1fc7f to e91e36b Compare August 10, 2026 11:28
Vitest 4 transpiles tests with target "node18" unless overridden, which
downlevels newer syntax. Importing the tsconfig target keeps tests on the
same language level that ships in dist, instead of a second hardcoded value
that can drift.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new Vitest decorators pre-transform accepts .tsx inputs but invokes esbuild with the TS (non-JSX) loader, which can break decorator-bearing TSX files.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Refactors retry behavior in the exchange package away from axios-retry interceptors toward a TC39-standard @retry decorator (and withRetry() helper), so each endpoint can declare its own retry/backoff policy without URL-based reverse mapping.

Changes:

  • Added withRetry() + @retry decorator (standard decorators) and a new unit test suite for the retry utility.
  • Migrated Trading212 and Alpaca API clients to decorator-based retry policies; updated related tests and simplified pagination retry semantics.
  • Updated toolchain configuration to support standard decorators in Vitest and removed legacy decorator compiler flags; removed axios-retry from dependencies/lockfile.
File summaries
File Description
vitest.shared.ts Reuses library TS target for tests and adds a pre-transform plugin to downlevel standard decorators via esbuild.
tsconfig.lib.json Removes legacy decorator compiler flags to avoid legacy decorator semantics.
packages/exchange/src/util/retry.ts Introduces withRetry(), isTransientHttpError(), and the @retry method decorator.
packages/exchange/src/util/retry.test.ts Adds tests validating retry behavior (delays, limits, non-retryables, private-field access).
packages/exchange/src/util/isEarningsDay.ts Replaces axios-retry usage with withRetry() using an AxiosError-based predicate.
packages/exchange/src/util/isEarningsDay.test.ts Removes axios-retry mocking no longer needed after refactor.
packages/exchange/src/broker/trading212/api/Trading212API.ts Moves per-endpoint retry policies to @retry decorators; refactors history pagination to retry per-page.
packages/exchange/src/broker/alpaca/api/AlpacaAPI.ts Replaces axios-retry interceptor config with a shared retryAlpaca decorator and SimplifiedHttpError-based retry predicate.
packages/exchange/src/broker/alpaca/api/AlpacaAPI.test.ts Updates retry predicate tests to use SimplifiedHttpError; keeps post-order retry behavior assertions.
packages/exchange/package.json Removes axios-retry dependency.
package-lock.json Removes axios-retry and its transitive dependency from the lockfile.
Review details
  • Files reviewed: 10/11 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread vitest.shared.ts
if (!/\.tsx?$/.test(id) || !/^\s*@[A-Za-z_$]/m.test(code)) {
return null;
}
return transform(code, {loader: 'ts', sourcefile: id, sourcemap: true, target});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants