@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.
- package/artifacts/InvestorClient.d.ts +7 -0
- package/artifacts/InvestorClient.ts +7 -0
- package/artifacts/LedgerClient.d.ts +7 -0
- package/artifacts/LedgerClient.ts +7 -0
- package/artifacts/LibraryClient.d.ts +7 -0
- package/artifacts/LibraryClient.ts +7 -0
- package/artifacts/OperationClient.d.ts +2 -0
- package/artifacts/OperationClient.ts +3 -0
- package/artifacts/OperatorClient.d.ts +40 -6
- package/artifacts/OperatorClient.js +171 -105
- package/artifacts/OperatorClient.ts +231 -134
- package/artifacts/QueryClient.d.ts +2 -0
- package/artifacts/QueryClient.ts +3 -0
- package/artifacts/SSEClient.d.ts +26 -0
- package/artifacts/SSEClient.js +28 -2
- package/artifacts/SSEClient.ts +51 -3
- package/artifacts/graphql/client.d.ts +8 -0
- package/artifacts/graphql/client.js +8 -1
- package/artifacts/graphql/client.ts +17 -1
- package/artifacts/hooks.js +5 -0
- package/artifacts/hooks.ts +5 -0
- package/artifacts/index.d.ts +7 -3
- package/artifacts/index.js +38 -3
- package/artifacts/index.ts +47 -5
- package/artifacts/retry.d.ts +59 -0
- package/artifacts/retry.js +121 -0
- package/artifacts/retry.ts +134 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|