@robosystems/client 1.12.2 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Rate-limit-aware `fetch` wrapper shared by the facade clients.
3
+ *
4
+ * The API rate-limits per user per endpoint category and answers an
5
+ * exhausted budget with `429` plus `Retry-After` / `X-RateLimit-*`
6
+ * headers. That rejection is raised by a request dependency *before*
7
+ * the endpoint handler runs, so the request had no effect and is always
8
+ * safe to replay — including a `POST` carrying no idempotency key.
9
+ * Nothing other than `429` is retried here, precisely because nothing
10
+ * else carries that guarantee.
11
+ *
12
+ * Mirrors `robosystems_client/clients/retry.py` in the Python client.
13
+ */
14
+
15
+ /** The one status a rejected request is known to have had no effect for. */
16
+ export const RETRY_STATUS_CODES = new Set([429])
17
+
18
+ export const DEFAULT_MAX_RETRIES = 5
19
+ export const DEFAULT_RETRY_DELAY_MS = 1000
20
+ export const MAX_BACKOFF_MS = 30_000
21
+
22
+ export interface RetryOptions {
23
+ /** Replays after the first attempt. `0` disables retrying entirely. */
24
+ maxRetries?: number
25
+ /** Base of the exponential backoff, in milliseconds. */
26
+ retryDelay?: number
27
+ /** Underlying fetch to wrap. Defaults to the global one, resolved per call. */
28
+ fetch?: typeof fetch
29
+ }
30
+
31
+ /**
32
+ * Parse `Retry-After` as a delta-seconds value, in milliseconds.
33
+ *
34
+ * The API always sends the numeric form. The HTTP-date form is ignored
35
+ * rather than parsed, since treating an unreadable value as "no hint"
36
+ * degrades to plain backoff instead of to a wrong sleep.
37
+ */
38
+ export function retryAfterMs(response: Response): number | null {
39
+ const raw = response.headers.get('retry-after')
40
+ if (!raw) {
41
+ return null
42
+ }
43
+ const seconds = Number(raw.trim())
44
+ if (!Number.isFinite(seconds) || seconds < 0) {
45
+ return null
46
+ }
47
+ return seconds * 1000
48
+ }
49
+
50
+ /**
51
+ * Milliseconds to wait before replaying a rate-limited request.
52
+ *
53
+ * Exponential with full jitter, and `Retry-After` applied as a
54
+ * *ceiling* rather than as the delay itself. The limiter is a sliding
55
+ * window, so `Retry-After` reports the whole window — the worst case
56
+ * for a client that filled its budget instantaneously. A caller that
57
+ * merely ran at the sustained rate has slots freeing up within a second
58
+ * or two, and obeying the header literally would turn a handful of
59
+ * rejections into minutes of idling.
60
+ */
61
+ export function backoffMs(attempt: number, retryDelay: number, retryAfter: number | null): number {
62
+ let ceiling = Math.min(retryDelay * 2 ** attempt, MAX_BACKOFF_MS)
63
+ if (retryAfter !== null) {
64
+ ceiling = Math.min(ceiling, retryAfter)
65
+ }
66
+ return ceiling / 2 + Math.random() * (ceiling / 2)
67
+ }
68
+
69
+ /**
70
+ * Whether a request can be sent a second time.
71
+ *
72
+ * A `ReadableStream` body is consumed by the first attempt, so
73
+ * replaying it would send nothing. Strings, `FormData`, `Blob` and
74
+ * typed arrays — everything the SDK and the GraphQL client actually
75
+ * produce — are re-readable.
76
+ */
77
+ function isReplayable(init: RequestInit | undefined): boolean {
78
+ const body = init?.body
79
+ return !(typeof ReadableStream !== 'undefined' && body instanceof ReadableStream)
80
+ }
81
+
82
+ function sleep(ms: number, signal?: AbortSignal | null): Promise<void> {
83
+ return new Promise((resolve, reject) => {
84
+ const timer = setTimeout(() => {
85
+ signal?.removeEventListener('abort', onAbort)
86
+ resolve()
87
+ }, ms)
88
+ function onAbort() {
89
+ clearTimeout(timer)
90
+ reject(signal?.reason ?? new DOMException('Aborted', 'AbortError'))
91
+ }
92
+ signal?.addEventListener('abort', onAbort, { once: true })
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Wrap a `fetch` so rate-limited requests are replayed.
98
+ *
99
+ * Composes over an existing fetch rather than replacing it, so a
100
+ * per-request timeout wrapper stays in place and each attempt gets its
101
+ * own timeout. The global `fetch` is resolved at call time (not
102
+ * captured) so test harnesses that swap `globalThis.fetch` keep
103
+ * working.
104
+ *
105
+ * Set `maxRetries: 0` to opt out — an interactive surface may well
106
+ * prefer to surface the rejection immediately rather than wait.
107
+ */
108
+ export function createRetryingFetch(options: RetryOptions = {}): typeof fetch {
109
+ const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES)
110
+ const retryDelay = Math.max(1, options.retryDelay ?? DEFAULT_RETRY_DELAY_MS)
111
+ const inner = options.fetch
112
+
113
+ return async (input, init) => {
114
+ const send = () => (inner ?? fetch)(input, init)
115
+ let response = await send()
116
+
117
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
118
+ if (!RETRY_STATUS_CODES.has(response.status) || !isReplayable(init)) {
119
+ return response
120
+ }
121
+ if (init?.signal?.aborted) {
122
+ return response
123
+ }
124
+ const delay = backoffMs(attempt, retryDelay, retryAfterMs(response))
125
+ // The rejection body goes unused, but leaving it undrained keeps
126
+ // the connection pinned in some runtimes.
127
+ await response.body?.cancel().catch(() => {})
128
+ await sleep(delay, init?.signal)
129
+ response = await send()
130
+ }
131
+
132
+ return response
133
+ }
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robosystems/client",
3
- "version": "1.12.2",
3
+ "version": "1.13.1",
4
4
  "description": "TypeScript client library for the RoboSystems financial intelligence platform",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",