@ttctl/core 0.0.0 → 0.1.0-rc.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/README.md +49 -9
- package/dist/__generated__/gateway.d.ts +4546 -0
- package/dist/__generated__/gateway.d.ts.map +1 -0
- package/dist/__generated__/gateway.js +9 -0
- package/dist/__generated__/gateway.js.map +1 -0
- package/dist/__generated__/talent-profile-zod-schemas.d.ts +1187 -0
- package/dist/__generated__/talent-profile-zod-schemas.d.ts.map +1 -0
- package/dist/__generated__/talent-profile-zod-schemas.js +1136 -0
- package/dist/__generated__/talent-profile-zod-schemas.js.map +1 -0
- package/dist/__generated__/talent-profile.d.ts +1397 -0
- package/dist/__generated__/talent-profile.d.ts.map +1 -0
- package/dist/__generated__/talent-profile.js +9 -0
- package/dist/__generated__/talent-profile.js.map +1 -0
- package/dist/__generated__/zod-schemas.d.ts +2895 -0
- package/dist/__generated__/zod-schemas.d.ts.map +1 -0
- package/dist/__generated__/zod-schemas.js +3121 -0
- package/dist/__generated__/zod-schemas.js.map +1 -0
- package/dist/__tests__/fixtures/profile/builders.d.ts +74 -0
- package/dist/__tests__/fixtures/profile/builders.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/builders.js +196 -0
- package/dist/__tests__/fixtures/profile/builders.js.map +1 -0
- package/dist/__tests__/fixtures/profile/data.d.ts +39 -0
- package/dist/__tests__/fixtures/profile/data.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/data.js +230 -0
- package/dist/__tests__/fixtures/profile/data.js.map +1 -0
- package/dist/__tests__/fixtures/profile/index.d.ts +9 -0
- package/dist/__tests__/fixtures/profile/index.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/index.js +10 -0
- package/dist/__tests__/fixtures/profile/index.js.map +1 -0
- package/dist/__tests__/fixtures/profile/types.d.ts +53 -0
- package/dist/__tests__/fixtures/profile/types.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/types.js +4 -0
- package/dist/__tests__/fixtures/profile/types.js.map +1 -0
- package/dist/auth/errors.d.ts +82 -0
- package/dist/auth/errors.d.ts.map +1 -0
- package/dist/auth/errors.js +68 -0
- package/dist/auth/errors.js.map +1 -0
- package/dist/auth.d.ts +192 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +294 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +212 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +349 -0
- package/dist/config.js.map +1 -0
- package/dist/configLock.d.ts +50 -0
- package/dist/configLock.d.ts.map +1 -0
- package/dist/configLock.js +88 -0
- package/dist/configLock.js.map +1 -0
- package/dist/configWriter.d.ts +97 -0
- package/dist/configWriter.d.ts.map +1 -0
- package/dist/configWriter.js +687 -0
- package/dist/configWriter.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/kill-switch.d.ts +161 -0
- package/dist/kill-switch.d.ts.map +1 -0
- package/dist/kill-switch.js +235 -0
- package/dist/kill-switch.js.map +1 -0
- package/dist/lib/date.d.ts +58 -0
- package/dist/lib/date.d.ts.map +1 -0
- package/dist/lib/date.js +104 -0
- package/dist/lib/date.js.map +1 -0
- package/dist/lib/diagnostic-log.d.ts +159 -0
- package/dist/lib/diagnostic-log.d.ts.map +1 -0
- package/dist/lib/diagnostic-log.js +186 -0
- package/dist/lib/diagnostic-log.js.map +1 -0
- package/dist/lib/package-version.d.ts +19 -0
- package/dist/lib/package-version.d.ts.map +1 -0
- package/dist/lib/package-version.js +38 -0
- package/dist/lib/package-version.js.map +1 -0
- package/dist/lib/redact.d.ts +153 -0
- package/dist/lib/redact.d.ts.map +1 -0
- package/dist/lib/redact.js +207 -0
- package/dist/lib/redact.js.map +1 -0
- package/dist/lib/text.d.ts +14 -0
- package/dist/lib/text.d.ts.map +1 -0
- package/dist/lib/text.js +21 -0
- package/dist/lib/text.js.map +1 -0
- package/dist/lib/wire-shape.d.ts +131 -0
- package/dist/lib/wire-shape.d.ts.map +1 -0
- package/dist/lib/wire-shape.js +376 -0
- package/dist/lib/wire-shape.js.map +1 -0
- package/dist/onepassword.d.ts +29 -0
- package/dist/onepassword.d.ts.map +1 -0
- package/dist/onepassword.js +112 -0
- package/dist/onepassword.js.map +1 -0
- package/dist/services/_shared/transport.d.ts +148 -0
- package/dist/services/_shared/transport.d.ts.map +1 -0
- package/dist/services/_shared/transport.js +102 -0
- package/dist/services/_shared/transport.js.map +1 -0
- package/dist/services/applications/index.d.ts +210 -0
- package/dist/services/applications/index.d.ts.map +1 -0
- package/dist/services/applications/index.js +240 -0
- package/dist/services/applications/index.js.map +1 -0
- package/dist/services/availability/index.d.ts +254 -0
- package/dist/services/availability/index.d.ts.map +1 -0
- package/dist/services/availability/index.js +310 -0
- package/dist/services/availability/index.js.map +1 -0
- package/dist/services/contracts/index.d.ts +132 -0
- package/dist/services/contracts/index.d.ts.map +1 -0
- package/dist/services/contracts/index.js +211 -0
- package/dist/services/contracts/index.js.map +1 -0
- package/dist/services/engagements/index.d.ts +504 -0
- package/dist/services/engagements/index.d.ts.map +1 -0
- package/dist/services/engagements/index.js +613 -0
- package/dist/services/engagements/index.js.map +1 -0
- package/dist/services/jobs/index.d.ts +490 -0
- package/dist/services/jobs/index.d.ts.map +1 -0
- package/dist/services/jobs/index.js +753 -0
- package/dist/services/jobs/index.js.map +1 -0
- package/dist/services/payments/index.d.ts +415 -0
- package/dist/services/payments/index.d.ts.map +1 -0
- package/dist/services/payments/index.js +636 -0
- package/dist/services/payments/index.js.map +1 -0
- package/dist/services/profile/__tests__/fixtures.d.ts +214 -0
- package/dist/services/profile/__tests__/fixtures.d.ts.map +1 -0
- package/dist/services/profile/__tests__/fixtures.js +176 -0
- package/dist/services/profile/__tests__/fixtures.js.map +1 -0
- package/dist/services/profile/basic/index.d.ts +390 -0
- package/dist/services/profile/basic/index.d.ts.map +1 -0
- package/dist/services/profile/basic/index.js +1007 -0
- package/dist/services/profile/basic/index.js.map +1 -0
- package/dist/services/profile/certifications/index.d.ts +74 -0
- package/dist/services/profile/certifications/index.d.ts.map +1 -0
- package/dist/services/profile/certifications/index.js +169 -0
- package/dist/services/profile/certifications/index.js.map +1 -0
- package/dist/services/profile/education/index.d.ts +73 -0
- package/dist/services/profile/education/index.d.ts.map +1 -0
- package/dist/services/profile/education/index.js +168 -0
- package/dist/services/profile/education/index.js.map +1 -0
- package/dist/services/profile/employment/index.d.ts +111 -0
- package/dist/services/profile/employment/index.d.ts.map +1 -0
- package/dist/services/profile/employment/index.js +202 -0
- package/dist/services/profile/employment/index.js.map +1 -0
- package/dist/services/profile/external/index.d.ts +219 -0
- package/dist/services/profile/external/index.d.ts.map +1 -0
- package/dist/services/profile/external/index.js +560 -0
- package/dist/services/profile/external/index.js.map +1 -0
- package/dist/services/profile/index.d.ts +24 -0
- package/dist/services/profile/index.d.ts.map +1 -0
- package/dist/services/profile/index.js +26 -0
- package/dist/services/profile/index.js.map +1 -0
- package/dist/services/profile/industries/index.d.ts +130 -0
- package/dist/services/profile/industries/index.d.ts.map +1 -0
- package/dist/services/profile/industries/index.js +292 -0
- package/dist/services/profile/industries/index.js.map +1 -0
- package/dist/services/profile/portfolio/index.d.ts +352 -0
- package/dist/services/profile/portfolio/index.d.ts.map +1 -0
- package/dist/services/profile/portfolio/index.js +833 -0
- package/dist/services/profile/portfolio/index.js.map +1 -0
- package/dist/services/profile/resume/index.d.ts +60 -0
- package/dist/services/profile/resume/index.d.ts.map +1 -0
- package/dist/services/profile/resume/index.js +212 -0
- package/dist/services/profile/resume/index.js.map +1 -0
- package/dist/services/profile/reviews/index.d.ts +137 -0
- package/dist/services/profile/reviews/index.d.ts.map +1 -0
- package/dist/services/profile/reviews/index.js +431 -0
- package/dist/services/profile/reviews/index.js.map +1 -0
- package/dist/services/profile/shared.d.ts +127 -0
- package/dist/services/profile/shared.d.ts.map +1 -0
- package/dist/services/profile/shared.js +155 -0
- package/dist/services/profile/shared.js.map +1 -0
- package/dist/services/profile/skills/index.d.ts +212 -0
- package/dist/services/profile/skills/index.d.ts.map +1 -0
- package/dist/services/profile/skills/index.js +461 -0
- package/dist/services/profile/skills/index.js.map +1 -0
- package/dist/services/profile/visas/index.d.ts +74 -0
- package/dist/services/profile/visas/index.d.ts.map +1 -0
- package/dist/services/profile/visas/index.js +306 -0
- package/dist/services/profile/visas/index.js.map +1 -0
- package/dist/services/timesheet/index.d.ts +326 -0
- package/dist/services/timesheet/index.d.ts.map +1 -0
- package/dist/services/timesheet/index.js +324 -0
- package/dist/services/timesheet/index.js.map +1 -0
- package/dist/services/translations.d.ts +79 -0
- package/dist/services/translations.d.ts.map +1 -0
- package/dist/services/translations.js +136 -0
- package/dist/services/translations.js.map +1 -0
- package/dist/transport-resilience.d.ts +136 -0
- package/dist/transport-resilience.d.ts.map +1 -0
- package/dist/transport-resilience.js +247 -0
- package/dist/transport-resilience.js.map +1 -0
- package/dist/transport.d.ts +408 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +691 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/package.json +40 -12
- package/index.js +0 -7
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Oleksii PELYKH
|
|
3
|
+
import { fetch as wreqFetch } from "node-wreq";
|
|
4
|
+
import { request as undiciRequest } from "undici";
|
|
5
|
+
import { TtctlError } from "./auth/errors.js";
|
|
6
|
+
import { logTransportRequest, logTransportResponse, logTransportRetry } from "./lib/diagnostic-log.js";
|
|
7
|
+
import { classifyTransportError, combineSignals, computeBackoffDelay, isRetryableStatus, parseRetryAfter, readTransportConfig, sleepUnlessAborted, TransportError, } from "./transport-resilience.js";
|
|
8
|
+
import { SURFACES_REQUIRING_IMPERSONATION, SURFACE_ENDPOINTS } from "./types.js";
|
|
9
|
+
/**
|
|
10
|
+
* Thrown when an impersonated surface returns HTTP 403.
|
|
11
|
+
*
|
|
12
|
+
* Empirically, Chrome TLS impersonation alone passes Cloudflare on the
|
|
13
|
+
* surfaces TTCtl currently uses (`talent-profile`, `scheduler`) — see
|
|
14
|
+
* `hq/engineering/adr/ADR-005-auth-model.md`. A 403 here therefore
|
|
15
|
+
* means Cloudflare has flipped a feature flag (e.g. activated a Turnstile
|
|
16
|
+
* challenge or a new bot-management heuristic) that we don't currently
|
|
17
|
+
* handle. There is no documented manual workaround; the user is asked to
|
|
18
|
+
* file an issue so we can investigate.
|
|
19
|
+
*
|
|
20
|
+
* Refined under issue #77 to extend `TtctlError`. Carries the stable
|
|
21
|
+
* `code = 'CF_403_CLEARANCE'` and a short `recovery` hint that the CLI /
|
|
22
|
+
* MCP surfaces render alongside the existing multi-line `message`.
|
|
23
|
+
*
|
|
24
|
+
* See also `Cf403PersistentError` (defined for future use when an explicit
|
|
25
|
+
* retry-with-fresh-clearance heuristic is added — currently TTCtl cannot
|
|
26
|
+
* distinguish "clearance expired" from "persistent block" at runtime, so
|
|
27
|
+
* `Cf403Error` is the only class actually thrown by the transport).
|
|
28
|
+
*/
|
|
29
|
+
export class Cf403Error extends TtctlError {
|
|
30
|
+
surface;
|
|
31
|
+
endpoint;
|
|
32
|
+
name = "Cf403Error";
|
|
33
|
+
code = "CF_403_CLEARANCE";
|
|
34
|
+
recovery = "Cloudflare returned 403. Try the request again; if the block persists, file an issue at " +
|
|
35
|
+
"https://github.com/alexey-pelykh/ttctl/issues with the surface name and a timestamp.";
|
|
36
|
+
constructor(surface, endpoint) {
|
|
37
|
+
super(Cf403Error.formatMessage(surface, endpoint));
|
|
38
|
+
this.surface = surface;
|
|
39
|
+
this.endpoint = endpoint;
|
|
40
|
+
}
|
|
41
|
+
static formatMessage(surface, endpoint) {
|
|
42
|
+
return [
|
|
43
|
+
`Cloudflare returned HTTP 403 from surface "${surface}" (${endpoint}).`,
|
|
44
|
+
"",
|
|
45
|
+
"Empirically, Chrome TLS impersonation alone passes Cloudflare in the happy path on this surface. " +
|
|
46
|
+
"A 403 here means Cloudflare's bot-management has flipped a feature flag we don't currently handle.",
|
|
47
|
+
"",
|
|
48
|
+
`Please file an issue at https://github.com/alexey-pelykh/ttctl/issues with the surface name ("${surface}") ` +
|
|
49
|
+
"and a timestamp so we can investigate.",
|
|
50
|
+
].join("\n");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Thrown when Cloudflare is *persistently* blocking an impersonated surface
|
|
55
|
+
* — i.e. clearance refresh and re-attempts have failed and the only
|
|
56
|
+
* remaining recovery is the cookie-jar break-glass path documented in
|
|
57
|
+
* `SECURITY.md`.
|
|
58
|
+
*
|
|
59
|
+
* **Currently defined for future use.** TTCtl's transport has no automated
|
|
60
|
+
* retry-with-fresh-clearance heuristic, so a single 403 cannot be
|
|
61
|
+
* distinguished from a persistent block at runtime. The transport throws
|
|
62
|
+
* the more general `Cf403Error`. When a future iteration adds retry +
|
|
63
|
+
* clearance refresh, that layer will re-classify a confirmed-persistent
|
|
64
|
+
* block to `Cf403PersistentError`. See issue #77 § Out of Scope.
|
|
65
|
+
*/
|
|
66
|
+
export class Cf403PersistentError extends TtctlError {
|
|
67
|
+
surface;
|
|
68
|
+
endpoint;
|
|
69
|
+
name = "Cf403PersistentError";
|
|
70
|
+
code = "CF_403_PERSISTENT";
|
|
71
|
+
recovery = "Cloudflare is persistently blocking this surface. The cookie-jar auxiliary auth path is the only " +
|
|
72
|
+
"remaining recovery — see SECURITY.md for break-glass details, and file an issue at " +
|
|
73
|
+
"https://github.com/alexey-pelykh/ttctl/issues so we can investigate.";
|
|
74
|
+
constructor(surface, endpoint, message = `Cloudflare persistently blocked surface "${surface}" (${endpoint}).`) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.surface = surface;
|
|
77
|
+
this.endpoint = endpoint;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Thrown when the scheduler bearer token has expired.
|
|
82
|
+
*
|
|
83
|
+
* **Scaffolded for post-v1 scheduler-surface coverage.** TTCtl currently
|
|
84
|
+
* has no scheduler operations wired in; transport routes scheduler →
|
|
85
|
+
* impersonated, but no service module issues scheduler GraphQL calls. When
|
|
86
|
+
* scheduler coverage lands (post-v1), this class will be thrown by the
|
|
87
|
+
* scheduler service module on bearer-expiry detection.
|
|
88
|
+
*
|
|
89
|
+
* `autoRecover = true` signals to the transport layer that an automated
|
|
90
|
+
* re-mint via `GetTopSchedulerToken` should be attempted once before
|
|
91
|
+
* surfacing this error. The auto-recovery contract is intentionally
|
|
92
|
+
* defined here so callers can plan against it; the actual re-mint
|
|
93
|
+
* orchestration ships with the scheduler surface implementation.
|
|
94
|
+
*/
|
|
95
|
+
export class SchedulerBearerExpired extends TtctlError {
|
|
96
|
+
name = "SchedulerBearerExpired";
|
|
97
|
+
code = "SCHEDULER_BEARER_EXPIRED";
|
|
98
|
+
recovery = "Scheduler bearer token expired; will be re-minted automatically on next call.";
|
|
99
|
+
autoRecover = true;
|
|
100
|
+
constructor(message = "Scheduler bearer token expired.") {
|
|
101
|
+
super(message);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Thrown when a transport receives an HTTP 3xx redirect carrying a
|
|
106
|
+
* `Location` header.
|
|
107
|
+
*
|
|
108
|
+
* TTCtl talks to fixed GraphQL endpoints; none of them legitimately
|
|
109
|
+
* redirect. A 3xx is therefore an anomaly — most plausibly Cloudflare or
|
|
110
|
+
* Toptal's edge flipping an infrastructure flag (mirror of the
|
|
111
|
+
* {@link Cf403Error} case), or a misconfigured DNS / edge change.
|
|
112
|
+
*
|
|
113
|
+
* Defense-in-depth posture (issue #268): every transport entry point has a
|
|
114
|
+
* no-follow redirect policy — `redirect: "manual"` pinned explicitly on
|
|
115
|
+
* `node-wreq`, and structurally on `undici` (its `request()` on the
|
|
116
|
+
* default dispatcher never follows redirects; redirect following is an
|
|
117
|
+
* opt-in interceptor TTCtl does not install). A 3xx is therefore returned
|
|
118
|
+
* verbatim rather than followed, and {@link executeWithResilience}
|
|
119
|
+
* inspects the status and throws this typed error rather than handing the
|
|
120
|
+
* redirect body back to a caller that would not know how to interpret it.
|
|
121
|
+
*
|
|
122
|
+
* Refusing to follow is the security-relevant property: a followed
|
|
123
|
+
* cross-origin redirect would leak the request body (operation name +
|
|
124
|
+
* variables) to the redirect target even though `node-wreq` strips the
|
|
125
|
+
* `authorization` header on cross-origin hops. Pinning `node-wreq`'s
|
|
126
|
+
* policy keeps that guarantee from depending on a transitive library
|
|
127
|
+
* default (it ships `redirect: "follow"` by default).
|
|
128
|
+
*
|
|
129
|
+
* Carries `surface`, `endpoint`, `status`, and `location` — the
|
|
130
|
+
* `Location` header value is a URL, not a credential, so it is safe to
|
|
131
|
+
* surface in the message and in diagnostic traces for operator triage.
|
|
132
|
+
*/
|
|
133
|
+
export class RedirectError extends TtctlError {
|
|
134
|
+
surface;
|
|
135
|
+
endpoint;
|
|
136
|
+
status;
|
|
137
|
+
location;
|
|
138
|
+
name = "RedirectError";
|
|
139
|
+
code = "REDIRECT_REFUSED";
|
|
140
|
+
recovery = "The Toptal API returned an unexpected HTTP redirect. GraphQL endpoints are not expected to redirect; " +
|
|
141
|
+
"this likely indicates a Toptal infrastructure change. File an issue at " +
|
|
142
|
+
"https://github.com/alexey-pelykh/ttctl/issues with the surface name, a timestamp, and the Location value " +
|
|
143
|
+
"from the error message.";
|
|
144
|
+
constructor(surface, endpoint, status, location) {
|
|
145
|
+
super(RedirectError.formatMessage(surface, endpoint, status, location));
|
|
146
|
+
this.surface = surface;
|
|
147
|
+
this.endpoint = endpoint;
|
|
148
|
+
this.status = status;
|
|
149
|
+
this.location = location;
|
|
150
|
+
}
|
|
151
|
+
static formatMessage(surface, endpoint, status, location) {
|
|
152
|
+
return [
|
|
153
|
+
`Transport refused to follow an HTTP ${status.toString()} redirect from surface "${surface}" (${endpoint}).`,
|
|
154
|
+
`Location: ${location}`,
|
|
155
|
+
"",
|
|
156
|
+
"TTCtl's GraphQL endpoints are not expected to redirect. Following the redirect is refused as a " +
|
|
157
|
+
"defense-in-depth measure — a followed redirect would carry the request body to the redirect target.",
|
|
158
|
+
"",
|
|
159
|
+
`Please file an issue at https://github.com/alexey-pelykh/ttctl/issues with the surface name ("${surface}"), ` +
|
|
160
|
+
"a timestamp, and the Location value above so we can investigate.",
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* HTTP status codes that signal a redirect when paired with a `Location`
|
|
166
|
+
* header. Matches `node-wreq`'s own `REDIRECT_STATUS_CODES` set
|
|
167
|
+
* (300, 301, 302, 303, 307, 308). `304 Not Modified` is intentionally
|
|
168
|
+
* excluded — it is a cache-validation response, not a redirect, and never
|
|
169
|
+
* carries a `Location`.
|
|
170
|
+
*/
|
|
171
|
+
const REDIRECT_STATUS_CODES = new Set([300, 301, 302, 303, 307, 308]);
|
|
172
|
+
/**
|
|
173
|
+
* Case-insensitive header lookup over a plain header object. HTTP header
|
|
174
|
+
* names are case-insensitive (RFC 9110 § 5.1), and the two transports
|
|
175
|
+
* normalise differently: `undici` lowercases response header keys, while
|
|
176
|
+
* `node-wreq`'s `Headers.toObject()` preserves the server's original
|
|
177
|
+
* casing. The redirect check must find `Location` regardless of which
|
|
178
|
+
* transport produced the response object.
|
|
179
|
+
*/
|
|
180
|
+
function getHeaderInsensitive(headers, name) {
|
|
181
|
+
const lower = name.toLowerCase();
|
|
182
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
183
|
+
if (key.toLowerCase() === lower)
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Return the `Location` value if `status` + `headers` describe an HTTP
|
|
190
|
+
* redirect — a {@link REDIRECT_STATUS_CODES} status carrying a `Location`
|
|
191
|
+
* header — and `undefined` otherwise.
|
|
192
|
+
*
|
|
193
|
+
* Shared by {@link executeWithResilience} and the photo-upload path's
|
|
194
|
+
* hand-rolled `node-wreq` fetch (`multipartImpersonatedFetch` in
|
|
195
|
+
* `services/profile/basic/index.ts`) so every transport path enforces the
|
|
196
|
+
* same no-follow posture (issue #268). A 3xx WITHOUT a `Location` header
|
|
197
|
+
* is not a redirect — there is nothing to follow — so it returns
|
|
198
|
+
* `undefined` and the caller returns the response verbatim.
|
|
199
|
+
*
|
|
200
|
+
* The caller decides what to do with a positive result: throw a
|
|
201
|
+
* {@link RedirectError}. Detection and reaction are split so each call
|
|
202
|
+
* site can emit its own diagnostic-trace record before throwing, matching
|
|
203
|
+
* how each already handles the {@link Cf403Error} case.
|
|
204
|
+
*/
|
|
205
|
+
export function getRedirectLocation(status, headers) {
|
|
206
|
+
if (!REDIRECT_STATUS_CODES.has(status))
|
|
207
|
+
return undefined;
|
|
208
|
+
return getHeaderInsensitive(headers, "location");
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* TLS-impersonation profile. Pinned as a coupled pair with `USER_AGENT` —
|
|
212
|
+
* see the `tls-fingerprinting` skill on identity-catalog freshness: WAFs
|
|
213
|
+
* cross-validate the User-Agent string against the JA4 hash, so the profile
|
|
214
|
+
* and UA must both name the same Chrome version. Bump them together when
|
|
215
|
+
* `node-wreq` publishes a newer profile.
|
|
216
|
+
*
|
|
217
|
+
* Currently `chrome_145` because that is the freshest profile published in
|
|
218
|
+
* `node-wreq@2.2.1`. The Rust upstream `wreq` crate has `chrome_146` in
|
|
219
|
+
* its release-candidate stream but the Node bindings have not yet shipped a
|
|
220
|
+
* matching release. Track upstream and bump.
|
|
221
|
+
*/
|
|
222
|
+
export const IMPERSONATE_PROFILE = "chrome_145";
|
|
223
|
+
const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
224
|
+
"(KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
|
|
225
|
+
const COMMON_HEADERS = {
|
|
226
|
+
accept: "*/*",
|
|
227
|
+
"accept-language": "en-US,en;q=0.9",
|
|
228
|
+
"content-type": "application/json",
|
|
229
|
+
origin: "https://talent.toptal.com",
|
|
230
|
+
referer: "https://talent.toptal.com/",
|
|
231
|
+
"sec-fetch-site": "same-site",
|
|
232
|
+
"user-agent": USER_AGENT,
|
|
233
|
+
// Mobile-app fingerprint alignment: the official Toptal mobile client sets
|
|
234
|
+
// this on every gateway call. Not load-bearing for auth (empirically the
|
|
235
|
+
// Token header alone is sufficient — see issue #59), but copying it into
|
|
236
|
+
// outgoing requests reduces fingerprint divergence over time and limits the
|
|
237
|
+
// surface area for header-shape heuristics to flag this client.
|
|
238
|
+
"x-toptal-analytics-origin": "mobile",
|
|
239
|
+
};
|
|
240
|
+
/**
|
|
241
|
+
* Token redaction marker used by {@link buildDryRunPreview} when an
|
|
242
|
+
* `authToken` is present on the source request. Exposed as a constant so
|
|
243
|
+
* tests assert on the exact wire shape and so future code never reaches
|
|
244
|
+
* for the bearer literal by mistake.
|
|
245
|
+
*/
|
|
246
|
+
export const DRY_RUN_REDACTED_AUTHORIZATION = "Token token=<redacted>";
|
|
247
|
+
/**
|
|
248
|
+
* Build a {@link DryRunPreview} from a {@link TransportRequest} without
|
|
249
|
+
* invoking any transport. Pure — no I/O, no allocations beyond the
|
|
250
|
+
* returned object.
|
|
251
|
+
*
|
|
252
|
+
* The headers projection mirrors what {@link stockTransport} and
|
|
253
|
+
* {@link impersonatedTransport} would have set (`COMMON_HEADERS` plus
|
|
254
|
+
* `authorization` when `authToken` is present), with the bearer value
|
|
255
|
+
* redacted to {@link DRY_RUN_REDACTED_AUTHORIZATION}. The transport
|
|
256
|
+
* classification is derived from
|
|
257
|
+
* {@link SURFACES_REQUIRING_IMPERSONATION} so changes to that set
|
|
258
|
+
* propagate automatically.
|
|
259
|
+
*
|
|
260
|
+
* Mutation entry points call this AFTER they've populated their
|
|
261
|
+
* `body.variables` with placeholder substitutions for any fields that
|
|
262
|
+
* would have been resolved at execution time (e.g. `profileId`) —
|
|
263
|
+
* keeping the read-side transport call out of the dry-run path entirely.
|
|
264
|
+
* See `set()` in `services/profile/basic/index.ts` for the pattern.
|
|
265
|
+
*/
|
|
266
|
+
export function buildDryRunPreview(req) {
|
|
267
|
+
const surface = req.surface;
|
|
268
|
+
const transport = SURFACES_REQUIRING_IMPERSONATION.has(surface) ? "impersonated" : "stock";
|
|
269
|
+
const headers = { ...COMMON_HEADERS };
|
|
270
|
+
if (req.authToken !== undefined) {
|
|
271
|
+
headers["authorization"] = DRY_RUN_REDACTED_AUTHORIZATION;
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
surface,
|
|
275
|
+
transport,
|
|
276
|
+
endpoint: SURFACE_ENDPOINTS[surface],
|
|
277
|
+
operationName: req.body.operationName,
|
|
278
|
+
variables: req.body.variables ?? {},
|
|
279
|
+
headers,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Choose transport per surface. The mobile gateway accepts stock TLS;
|
|
284
|
+
* `talent-profile` and `scheduler` require Chrome TLS-fingerprint impersonation
|
|
285
|
+
* to clear Cloudflare's bot-management.
|
|
286
|
+
*/
|
|
287
|
+
export async function callSurface(req) {
|
|
288
|
+
if (SURFACES_REQUIRING_IMPERSONATION.has(req.surface)) {
|
|
289
|
+
return impersonatedTransport(req);
|
|
290
|
+
}
|
|
291
|
+
return stockTransport(req);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Stock HTTP via undici. Used for the mobile gateway endpoint, which doesn't
|
|
295
|
+
* gate on TLS fingerprint.
|
|
296
|
+
*
|
|
297
|
+
* Resilience (#229): wraps the network call in a retry loop that handles
|
|
298
|
+
* HTTP 429 (with `Retry-After` honoring) and 5xx with bounded exponential
|
|
299
|
+
* backoff, applies a per-attempt timeout, and propagates the caller's
|
|
300
|
+
* `AbortSignal` so an MCP client cancel actually tears down the in-flight
|
|
301
|
+
* request. Final failures surface as a typed {@link TransportError}.
|
|
302
|
+
*/
|
|
303
|
+
export async function stockTransport(req) {
|
|
304
|
+
const url = SURFACE_ENDPOINTS[req.surface];
|
|
305
|
+
const headers = { ...COMMON_HEADERS };
|
|
306
|
+
if (req.authToken)
|
|
307
|
+
headers["authorization"] = `Token token=${req.authToken}`;
|
|
308
|
+
// Diagnostic log hook (issue #139). No-op when --verbose/--debug
|
|
309
|
+
// are absent; otherwise emits a redacted request line/record to
|
|
310
|
+
// stderr. Records start time outside the disabled-fast-path so
|
|
311
|
+
// performance.now() is paid only when a logger is active.
|
|
312
|
+
logTransportRequest({
|
|
313
|
+
surface: req.surface,
|
|
314
|
+
endpoint: url,
|
|
315
|
+
transport: "stock",
|
|
316
|
+
method: "POST",
|
|
317
|
+
operationName: req.body.operationName,
|
|
318
|
+
headers,
|
|
319
|
+
body: req.body,
|
|
320
|
+
});
|
|
321
|
+
const body = JSON.stringify(req.body);
|
|
322
|
+
// No-follow redirect policy (issue #268). `undici.request()` with the
|
|
323
|
+
// default global dispatcher does NOT follow redirects — redirect
|
|
324
|
+
// following is an opt-in interceptor (`undici.interceptors.redirect`)
|
|
325
|
+
// that TTCtl never installs. The guarantee is structural, not a
|
|
326
|
+
// default-value that a future major could flip, so there is no explicit
|
|
327
|
+
// `redirect` / `maxRedirections` option to pin here (the request-level
|
|
328
|
+
// options type has none). A 3xx response is surfaced verbatim and
|
|
329
|
+
// rejected by `executeWithResilience` as a typed `RedirectError`.
|
|
330
|
+
return executeWithResilience(req, url, async (signal) => {
|
|
331
|
+
const res = await undiciRequest(url, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers,
|
|
334
|
+
body,
|
|
335
|
+
signal,
|
|
336
|
+
headersTimeout: readTransportConfig().timeoutMs,
|
|
337
|
+
bodyTimeout: readTransportConfig().timeoutMs,
|
|
338
|
+
});
|
|
339
|
+
const text = await res.body.text();
|
|
340
|
+
let parsed;
|
|
341
|
+
try {
|
|
342
|
+
parsed = JSON.parse(text);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
parsed = text;
|
|
346
|
+
}
|
|
347
|
+
const responseHeaders = {};
|
|
348
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
349
|
+
if (Array.isArray(v)) {
|
|
350
|
+
responseHeaders[k] = v.join(", ");
|
|
351
|
+
}
|
|
352
|
+
else if (typeof v === "string") {
|
|
353
|
+
responseHeaders[k] = v;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return { status: res.statusCode, headers: responseHeaders, body: parsed };
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Impersonated HTTP via `node-wreq` (Rust + BoringSSL). Used for the
|
|
361
|
+
* Cloudflare-protected `talent-profile` and `scheduler` surfaces.
|
|
362
|
+
*
|
|
363
|
+
* The `browser` option drives node-wreq's TLS ClientHello and HTTP/2
|
|
364
|
+
* SETTINGS frame to match the bundled Chrome profile (see `IMPERSONATE_PROFILE`).
|
|
365
|
+
* Empirically this fingerprint alone clears Cloudflare on the surfaces TTCtl
|
|
366
|
+
* uses; no `cf_clearance` cookie is required in the happy path.
|
|
367
|
+
*
|
|
368
|
+
* Header-tuple ordering is left as a future tightening — currently we pass
|
|
369
|
+
* a plain `Record<string, string>` matching `stockTransport`'s shape so the
|
|
370
|
+
* two transports stay symmetric. JA4H header-name ordering is a secondary
|
|
371
|
+
* detection vector relative to JA4 / Akamai HTTP/2; revisit if empirical
|
|
372
|
+
* blocks indicate it matters.
|
|
373
|
+
*
|
|
374
|
+
* Resilience (#229): wraps the network call in the same retry / timeout /
|
|
375
|
+
* abort loop as {@link stockTransport}. `Cf403Error` propagates as a
|
|
376
|
+
* non-retryable typed error — the 403 is signalled to Cloudflare's
|
|
377
|
+
* bot-management WAF, not a transient condition.
|
|
378
|
+
*/
|
|
379
|
+
export async function impersonatedTransport(req) {
|
|
380
|
+
const url = SURFACE_ENDPOINTS[req.surface];
|
|
381
|
+
const headers = { ...COMMON_HEADERS };
|
|
382
|
+
if (req.authToken)
|
|
383
|
+
headers["authorization"] = `Token token=${req.authToken}`;
|
|
384
|
+
// Diagnostic log hook (issue #139); see stockTransport for the
|
|
385
|
+
// disabled-fast-path rationale.
|
|
386
|
+
logTransportRequest({
|
|
387
|
+
surface: req.surface,
|
|
388
|
+
endpoint: url,
|
|
389
|
+
transport: "impersonated",
|
|
390
|
+
method: "POST",
|
|
391
|
+
operationName: req.body.operationName,
|
|
392
|
+
headers,
|
|
393
|
+
body: req.body,
|
|
394
|
+
});
|
|
395
|
+
const body = JSON.stringify(req.body);
|
|
396
|
+
return executeWithResilience(req, url, async (signal, startMs) => {
|
|
397
|
+
const res = await wreqFetch(url, {
|
|
398
|
+
method: "POST",
|
|
399
|
+
headers,
|
|
400
|
+
body,
|
|
401
|
+
browser: IMPERSONATE_PROFILE,
|
|
402
|
+
signal,
|
|
403
|
+
timeout: readTransportConfig().timeoutMs,
|
|
404
|
+
connectTimeout: readTransportConfig().connectMs,
|
|
405
|
+
// No-follow redirect policy (issue #268). `node-wreq` defaults to
|
|
406
|
+
// `redirect: "follow"` (up to 20 hops). It does strip the
|
|
407
|
+
// `authorization` header on cross-origin hops, but a followed
|
|
408
|
+
// redirect would still carry the request body (operation name +
|
|
409
|
+
// variables) to the redirect target. Pinning `"manual"` returns the
|
|
410
|
+
// 3xx verbatim so `executeWithResilience` can reject it as a typed
|
|
411
|
+
// `RedirectError` — and keeps the no-leak guarantee from depending
|
|
412
|
+
// on a transitive library default.
|
|
413
|
+
redirect: "manual",
|
|
414
|
+
});
|
|
415
|
+
const responseHeaders = res.headers.toObject();
|
|
416
|
+
if (res.status === 403) {
|
|
417
|
+
// Capture the 403 response shape in the diagnostic trace BEFORE
|
|
418
|
+
// throwing — the caller's exception handler does not see the wire
|
|
419
|
+
// details and operators investigating a Cloudflare block need the
|
|
420
|
+
// headers (cf-ray, cf-mitigated) to paste into a triage issue.
|
|
421
|
+
logTransportResponse({
|
|
422
|
+
surface: req.surface,
|
|
423
|
+
endpoint: url,
|
|
424
|
+
operationName: req.body.operationName,
|
|
425
|
+
status: 403,
|
|
426
|
+
headers: responseHeaders,
|
|
427
|
+
body: null,
|
|
428
|
+
elapsedMs: performance.now() - startMs,
|
|
429
|
+
});
|
|
430
|
+
throw new Cf403Error(req.surface, url);
|
|
431
|
+
}
|
|
432
|
+
const text = await res.text();
|
|
433
|
+
let parsed;
|
|
434
|
+
try {
|
|
435
|
+
parsed = JSON.parse(text);
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
parsed = text;
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
status: res.status,
|
|
442
|
+
headers: responseHeaders,
|
|
443
|
+
body: parsed,
|
|
444
|
+
};
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Build a `globalThis.FormData` payload conforming to the GraphQL multipart
|
|
449
|
+
* request spec (https://github.com/jaydenseric/graphql-multipart-request-spec).
|
|
450
|
+
* The wire layout is:
|
|
451
|
+
*
|
|
452
|
+
* ```
|
|
453
|
+
* --boundary
|
|
454
|
+
* Content-Disposition: form-data; name="operations"
|
|
455
|
+
* <JSON-encoded { operationName, query, variables }>
|
|
456
|
+
*
|
|
457
|
+
* --boundary
|
|
458
|
+
* Content-Disposition: form-data; name="map"
|
|
459
|
+
* <JSON-encoded { "0": ["variables.input.file"], ... }>
|
|
460
|
+
*
|
|
461
|
+
* --boundary
|
|
462
|
+
* Content-Disposition: form-data; name="0"; filename="<filename>"
|
|
463
|
+
* Content-Type: <contentType>
|
|
464
|
+
* <binary>
|
|
465
|
+
*
|
|
466
|
+
* --boundary--
|
|
467
|
+
* ```
|
|
468
|
+
*
|
|
469
|
+
* `node-wreq`'s `BodyInit` accepts `FormData` directly (verified against
|
|
470
|
+
* `node-wreq@2.2.1`'s `dist/types/shared.d.ts` — `BodyInit` includes
|
|
471
|
+
* `FormData`). When the body is a `FormData`, the runtime sets the
|
|
472
|
+
* `Content-Type: multipart/form-data; boundary=...` header automatically;
|
|
473
|
+
* the caller should NOT pre-set a JSON content-type or it will be
|
|
474
|
+
* overwritten with the boundary-aware multipart one.
|
|
475
|
+
*
|
|
476
|
+
* Pure function — no I/O. Tests construct expected `FormData` instances
|
|
477
|
+
* and inspect the entries via `for-of` iteration.
|
|
478
|
+
*/
|
|
479
|
+
export function buildGraphQLMultipart(body, files, map) {
|
|
480
|
+
const form = new FormData();
|
|
481
|
+
form.append("operations", JSON.stringify(body));
|
|
482
|
+
form.append("map", JSON.stringify(map));
|
|
483
|
+
for (const [slot, file] of Object.entries(files)) {
|
|
484
|
+
const blob = new Blob([new Uint8Array(file.content)], {
|
|
485
|
+
type: file.contentType ?? "application/octet-stream",
|
|
486
|
+
});
|
|
487
|
+
form.append(slot, blob, file.filename);
|
|
488
|
+
}
|
|
489
|
+
return form;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Multipart variant of {@link impersonatedTransport}. Sends a
|
|
493
|
+
* GraphQL-multipart-spec request through the impersonated transport so
|
|
494
|
+
* file-upload mutations (`uploadResume`, `uploadPortfolioCover`,
|
|
495
|
+
* `uploadPortfolioFile`) clear Cloudflare on the `talent-profile` surface.
|
|
496
|
+
*
|
|
497
|
+
* Why a separate function rather than overloading `impersonatedTransport`:
|
|
498
|
+
* the JSON path sets `content-type: application/json` and stringifies the
|
|
499
|
+
* body; the multipart path lets the runtime supply the multipart
|
|
500
|
+
* content-type with its own boundary and passes the `FormData` through
|
|
501
|
+
* unchanged. The two paths have different body wire formats and different
|
|
502
|
+
* header expectations, so they are kept as separate functions for clarity.
|
|
503
|
+
*
|
|
504
|
+
* Errors:
|
|
505
|
+
* - `Cf403Error` on HTTP 403 (Cloudflare bot-management has tightened — see
|
|
506
|
+
* the `Cf403Error` doc-comment for the recovery hint).
|
|
507
|
+
* - All other transport-level failures propagate as the underlying
|
|
508
|
+
* `node-wreq` error; service callers wrap them in their domain-specific
|
|
509
|
+
* `*Error` with `code: 'NETWORK_ERROR'`.
|
|
510
|
+
*/
|
|
511
|
+
export async function impersonatedMultipartTransport(req) {
|
|
512
|
+
const url = SURFACE_ENDPOINTS[req.surface];
|
|
513
|
+
const headers = { ...COMMON_HEADERS };
|
|
514
|
+
// Strip the JSON content-type — when the body is a `FormData`, node-wreq
|
|
515
|
+
// (like the platform `fetch`) sets the multipart/form-data content-type
|
|
516
|
+
// with its own boundary parameter. Leaving the JSON one would either
|
|
517
|
+
// get overwritten silently or, worse, lock the runtime onto a header
|
|
518
|
+
// that doesn't match the wire body and Cloudflare flags as malformed.
|
|
519
|
+
delete headers["content-type"];
|
|
520
|
+
if (req.authToken)
|
|
521
|
+
headers["authorization"] = `Token token=${req.authToken}`;
|
|
522
|
+
// Diagnostic log hook (issue #139). Multipart binary payloads are
|
|
523
|
+
// intentionally NOT logged — only the file slot labels + map are
|
|
524
|
+
// surfaced, which is enough to understand what was uploaded without
|
|
525
|
+
// dumping arbitrary bytes into a terminal.
|
|
526
|
+
logTransportRequest({
|
|
527
|
+
surface: req.surface,
|
|
528
|
+
endpoint: url,
|
|
529
|
+
transport: "impersonated-multipart",
|
|
530
|
+
method: "POST",
|
|
531
|
+
operationName: req.body.operationName,
|
|
532
|
+
headers,
|
|
533
|
+
body: req.body,
|
|
534
|
+
multipart: { files: Object.keys(req.files), map: req.map },
|
|
535
|
+
});
|
|
536
|
+
return executeWithResilience(req, url, async (signal, startMs) => {
|
|
537
|
+
// FormData is rebuilt per attempt — the underlying `Blob` slices read
|
|
538
|
+
// a fresh stream each time, so a retry of an aborted upload does not
|
|
539
|
+
// attempt to replay an already-consumed body.
|
|
540
|
+
const formData = buildGraphQLMultipart(req.body, req.files, req.map);
|
|
541
|
+
const res = await wreqFetch(url, {
|
|
542
|
+
method: "POST",
|
|
543
|
+
headers,
|
|
544
|
+
body: formData,
|
|
545
|
+
browser: IMPERSONATE_PROFILE,
|
|
546
|
+
signal,
|
|
547
|
+
timeout: readTransportConfig().timeoutMs,
|
|
548
|
+
connectTimeout: readTransportConfig().connectMs,
|
|
549
|
+
// No-follow redirect policy (issue #268). File-upload mutations are
|
|
550
|
+
// the highest-impact body-exfiltration vector if redirect handling
|
|
551
|
+
// weakens — see the rationale on the JSON `impersonatedTransport`
|
|
552
|
+
// call site above.
|
|
553
|
+
redirect: "manual",
|
|
554
|
+
});
|
|
555
|
+
const responseHeaders = res.headers.toObject();
|
|
556
|
+
if (res.status === 403) {
|
|
557
|
+
logTransportResponse({
|
|
558
|
+
surface: req.surface,
|
|
559
|
+
endpoint: url,
|
|
560
|
+
operationName: req.body.operationName,
|
|
561
|
+
status: 403,
|
|
562
|
+
headers: responseHeaders,
|
|
563
|
+
body: null,
|
|
564
|
+
elapsedMs: performance.now() - startMs,
|
|
565
|
+
});
|
|
566
|
+
throw new Cf403Error(req.surface, url);
|
|
567
|
+
}
|
|
568
|
+
const text = await res.text();
|
|
569
|
+
let parsed;
|
|
570
|
+
try {
|
|
571
|
+
parsed = JSON.parse(text);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
parsed = text;
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
status: res.status,
|
|
578
|
+
headers: responseHeaders,
|
|
579
|
+
body: parsed,
|
|
580
|
+
};
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Shared retry / timeout / abort loop used by all three transport entry
|
|
585
|
+
* points ({@link stockTransport}, {@link impersonatedTransport},
|
|
586
|
+
* {@link impersonatedMultipartTransport}).
|
|
587
|
+
*
|
|
588
|
+
* `attemptFn` performs a single network attempt against the supplied
|
|
589
|
+
* combined signal and returns the parsed {@link TransportResponse}. The
|
|
590
|
+
* loop:
|
|
591
|
+
*
|
|
592
|
+
* 1. Composes the caller's signal with a per-attempt timeout signal.
|
|
593
|
+
* 2. Calls `attemptFn`.
|
|
594
|
+
* 3. On retryable status (429, 5xx), backs off per the issue #229 policy
|
|
595
|
+
* and re-attempts up to the configured `maxRetries`.
|
|
596
|
+
* 4. On per-attempt timeout, surfaces as a {@link TransportError} with
|
|
597
|
+
* code `TIMEOUT` (no retry — the wedge is likely persistent).
|
|
598
|
+
* 5. On caller abort, surfaces as a {@link TransportError} with code
|
|
599
|
+
* `ABORTED`.
|
|
600
|
+
* 6. On non-retryable errors ({@link Cf403Error}, network failure),
|
|
601
|
+
* re-throws.
|
|
602
|
+
*
|
|
603
|
+
* Every successful attempt invokes the response logger; the final attempt
|
|
604
|
+
* also logs the surfaced status code so an operator inspecting a stderr
|
|
605
|
+
* trace can correlate retry events with the eventual outcome.
|
|
606
|
+
*/
|
|
607
|
+
async function executeWithResilience(req, url, attemptFn) {
|
|
608
|
+
const config = readTransportConfig();
|
|
609
|
+
let attempt = 0;
|
|
610
|
+
let lastStatus;
|
|
611
|
+
let lastRetryAfterMs;
|
|
612
|
+
for (;;) {
|
|
613
|
+
const { signal, dispose } = combineSignals(req.signal, config.timeoutMs);
|
|
614
|
+
const startMs = performance.now();
|
|
615
|
+
let res;
|
|
616
|
+
try {
|
|
617
|
+
res = await attemptFn(signal, startMs);
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
dispose();
|
|
621
|
+
const classification = classifyTransportError(err, req.signal);
|
|
622
|
+
if (classification === "aborted-by-caller") {
|
|
623
|
+
throw new TransportError("ABORTED", req.surface, url, attempt + 1, `Request to ${req.surface} cancelled by caller.`, lastStatus, lastRetryAfterMs, { cause: err });
|
|
624
|
+
}
|
|
625
|
+
if (classification === "timeout") {
|
|
626
|
+
throw new TransportError("TIMEOUT", req.surface, url, attempt + 1, `Request to ${req.surface} timed out after ${config.timeoutMs.toString()}ms.`, lastStatus, lastRetryAfterMs, { cause: err });
|
|
627
|
+
}
|
|
628
|
+
// Cf403Error, TtctlError subclasses, and arbitrary network errors
|
|
629
|
+
// propagate as-is — they are caller-visible failures, not transient
|
|
630
|
+
// conditions the retry loop should silently swallow.
|
|
631
|
+
throw err;
|
|
632
|
+
}
|
|
633
|
+
dispose();
|
|
634
|
+
logTransportResponse({
|
|
635
|
+
surface: req.surface,
|
|
636
|
+
endpoint: url,
|
|
637
|
+
operationName: req.body.operationName,
|
|
638
|
+
status: res.status,
|
|
639
|
+
headers: res.headers,
|
|
640
|
+
body: res.body,
|
|
641
|
+
elapsedMs: performance.now() - startMs,
|
|
642
|
+
});
|
|
643
|
+
// Redirect anomaly (issue #268). The transports have a no-follow
|
|
644
|
+
// policy (`redirect: "manual"` on node-wreq; structural on undici, see
|
|
645
|
+
// stockTransport), so a genuine 3xx-with-Location lands here verbatim
|
|
646
|
+
// instead of being followed. GraphQL endpoints are not expected to
|
|
647
|
+
// redirect; refuse to hand the redirect body back to a caller that
|
|
648
|
+
// cannot interpret it and surface a typed RedirectError for operator
|
|
649
|
+
// triage. The response is already captured in the diagnostic trace
|
|
650
|
+
// above. A 3xx WITHOUT a `Location` header is not a redirect — it
|
|
651
|
+
// falls through to the normal (non-retryable) return path so the
|
|
652
|
+
// caller's GraphQL response handler sees it verbatim.
|
|
653
|
+
const redirectLocation = getRedirectLocation(res.status, res.headers);
|
|
654
|
+
if (redirectLocation !== undefined) {
|
|
655
|
+
throw new RedirectError(req.surface, url, res.status, redirectLocation);
|
|
656
|
+
}
|
|
657
|
+
if (!isRetryableStatus(res.status)) {
|
|
658
|
+
return res;
|
|
659
|
+
}
|
|
660
|
+
// Retryable status — decide whether to retry or surface.
|
|
661
|
+
lastStatus = res.status;
|
|
662
|
+
if (attempt >= config.maxRetries) {
|
|
663
|
+
const code = res.status === 429 ? "RATE_LIMITED" : "SERVER_ERROR";
|
|
664
|
+
throw new TransportError(code, req.surface, url, attempt + 1, `Transport ${req.surface} returned HTTP ${res.status.toString()} after ${(attempt + 1).toString()} attempts.`, lastStatus, lastRetryAfterMs);
|
|
665
|
+
}
|
|
666
|
+
const reason = res.status === 429 ? "rate-limit" : "server-error";
|
|
667
|
+
const retryAfterRaw = res.headers["retry-after"];
|
|
668
|
+
const retryAfterMs = parseRetryAfter(retryAfterRaw);
|
|
669
|
+
lastRetryAfterMs = retryAfterMs;
|
|
670
|
+
const delayMs = retryAfterMs ?? computeBackoffDelay(reason, attempt);
|
|
671
|
+
logTransportRetry({
|
|
672
|
+
surface: req.surface,
|
|
673
|
+
endpoint: url,
|
|
674
|
+
operationName: req.body.operationName,
|
|
675
|
+
attempt: attempt + 1,
|
|
676
|
+
reason,
|
|
677
|
+
status: res.status,
|
|
678
|
+
delayMs,
|
|
679
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
680
|
+
});
|
|
681
|
+
try {
|
|
682
|
+
await sleepUnlessAborted(delayMs, req.signal);
|
|
683
|
+
}
|
|
684
|
+
catch (err) {
|
|
685
|
+
// Caller aborted during backoff sleep.
|
|
686
|
+
throw new TransportError("ABORTED", req.surface, url, attempt + 1, `Request to ${req.surface} cancelled by caller during backoff.`, lastStatus, lastRetryAfterMs, { cause: err });
|
|
687
|
+
}
|
|
688
|
+
attempt += 1;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
//# sourceMappingURL=transport.js.map
|