Skip to content

Commit f3795dd

Browse files
justin-layervclaude
andcommitted
fix: address review — request_id on shape-guard, target_url dedup, retry/index hardening
6 items. ### Load-bearing 1. **`request_id` propagated to shape-guard ValidationError.** Operators debugging "unexpected response" tickets need the server-side correlation ID on the *error* path too, not just the success/passthrough returns. Added an optional `request_id` parameter to `unexpectedResponseError` and threaded `envelope.meta?.request_id` through every shape-guard branch in `validateBatchCreateResponse`. New regression test asserts the requestId surfaces on ValidationError. ### Correctness 2. **De-duplicated target_url validation.** `requireValidTargetUrl` and `requireMaxLength(target_url, ...)` both ran under `collect()`, both ran their own typeof guard, and a non-string target_url produced two "must be a string" messages in the aggregated detail. Inlined the length check into `requireValidTargetUrl` (priority order: type → scheme → length, fail-fast within the field). The collect-all loop in validateCreateInput is unchanged at the field level. 3. **`batchItemResultValidationReason` index check tightened.** Was `typeof e.index !== "number"`, which let NaN, Infinity, negatives, and floats through. `Number.isInteger(...) && ... >= 0` matches the same posture used by the count integer guards on the surrounding shape guard. 4. **`parseRetryAfter` rejects trailing garbage.** `parseInt("60abc", 10)` returns 60 — the SDK would have silently honored a malformed Retry-After header. Added a digit-only regex pre-check so any deviation surfaces via the existing debug log instead. New test asserts a 429 with a `60abc` header produces an error with `.retryAfter === undefined` and the debug log fires. ### JSDoc / clarity 5. **`mapQurlsField`: explained why `"qurls" in raw` (not truthy)** — empty array `qurls: []` is truthy as a value but the rename must still happen so consumers see `access_tokens: []`. Locked in by the existing test. 6. **`MintInput`: explained why mutual-exclusion stays runtime-only** — `ExtendInput` requires *exactly one* of two fields (clean `T1 | T2`); `MintInput` allows *at most one* (zero is valid; server applies 24h default), which would need a noisier three-arm union. Runtime check catches "both provided" at minimal cost. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 5b0e51f commit f3795dd

3 files changed

Lines changed: 136 additions & 17 deletions

File tree

src/client.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2020,6 +2020,56 @@ describe("QURLClient", () => {
20202020
}
20212021
});
20222022

2023+
it("rejects Retry-After with trailing garbage (e.g. '60abc') as non-numeric", async () => {
2024+
// `parseInt("60abc", 10)` returns 60 — a malformed header would
2025+
// silently get honored without the digit-only pre-check. The strict
2026+
// regex makes the malformed-header path observable (debug log) and
2027+
// falls through to exponential backoff so the SDK doesn't act on
2028+
// a half-parsed value.
2029+
const fetch = mockFetch({
2030+
status: 200,
2031+
body: {
2032+
data: { plan: "growth", period_start: "2026-03-01", period_end: "2026-04-01" },
2033+
},
2034+
headers: { "Retry-After": "60abc" },
2035+
});
2036+
let lastDebug: string | undefined;
2037+
const client = new QURLClient({
2038+
apiKey: "lv_live_test",
2039+
baseUrl: "https://api.test.layerv.ai",
2040+
fetch,
2041+
maxRetries: 0,
2042+
debug: (msg) => {
2043+
lastDebug = msg;
2044+
},
2045+
});
2046+
// The 200 success path doesn't actually call parseRetryAfter
2047+
// (only 429 does). Use the QURLError construction path: a 429
2048+
// with malformed header surfaces .retryAfter === undefined.
2049+
const fetch429 = mockFetch({
2050+
status: 429,
2051+
body: { error: { title: "Rate Limited", status: 429, detail: "x", code: "rate_limited" } },
2052+
headers: { "Retry-After": "60abc" },
2053+
});
2054+
const client429 = new QURLClient({
2055+
apiKey: "lv_live_test",
2056+
baseUrl: "https://api.test.layerv.ai",
2057+
fetch: fetch429,
2058+
maxRetries: 0,
2059+
debug: (msg) => {
2060+
lastDebug = msg;
2061+
},
2062+
});
2063+
const err = (await client429.getQuota().catch((e: unknown) => e)) as QURLError;
2064+
expect(err).toBeInstanceOf(RateLimitError);
2065+
// Malformed Retry-After must NOT propagate — retryAfter stays undefined.
2066+
expect(err.retryAfter).toBeUndefined();
2067+
// Debug log fires so operators can spot the drift.
2068+
expect(lastDebug).toContain("non-numeric");
2069+
// Sanity-check the 200-path client wasn't broken by the Retry-After test.
2070+
await expect(client.getQuota()).resolves.toBeDefined();
2071+
});
2072+
20232073
it("does not clamp Retry-After against RETRY_MAX_DELAY_MS (server directive wins)", async () => {
20242074
// RFC 7231 §7.1.3 — when the server tells us 60s, retrying at 30s
20252075
// (the exponential-backoff cap) just re-trips the rate limit.
@@ -4027,6 +4077,29 @@ describe("QURLClient", () => {
40274077
expect(result.request_id).toBe("req_mixed207");
40284078
});
40294079

4080+
it("batch create propagates request_id to shape-guard ValidationError", async () => {
4081+
// Operators debugging "unexpected response" tickets need the
4082+
// server-side correlation ID on the *error* path too — not just
4083+
// on the success/passthrough returns. Locks in that
4084+
// unexpectedResponseError carries `meta.request_id` through to
4085+
// QURLError.requestId.
4086+
const fetch = mockFetch({
4087+
status: 400,
4088+
body: {
4089+
data: { unexpected: "not a batch response" },
4090+
meta: { request_id: "req_shape_guard_correlation" },
4091+
},
4092+
});
4093+
4094+
const client = createClient(fetch);
4095+
const error = await client
4096+
.batchCreate({ items: [{ target_url: "https://example.com" }] })
4097+
.catch((e: unknown) => e as ValidationError);
4098+
expect(error).toBeInstanceOf(ValidationError);
4099+
expect((error as ValidationError).code).toBe("unexpected_response");
4100+
expect((error as ValidationError).requestId).toBe("req_shape_guard_correlation");
4101+
});
4102+
40304103
it("batch create surfaces ValidationError on unexpected 400 response shape", async () => {
40314104
// If the API ever returns HTTP 400 with a non-BatchCreateOutput body
40324105
// (e.g., a top-level malformed-request error), batchCreate should

src/client.ts

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,19 @@ function clientValidationError(detail: string): ValidationError {
133133
* server returned a body I can't interpret" (`"unexpected_response"`).
134134
* Uses `status: 0` because the offending HTTP status (400/207/etc.) isn't
135135
* the thing being reported — the shape mismatch is.
136+
*
137+
* Threads `request_id` through to {@link QURLError.requestId} when the
138+
* server-side correlation ID is available — operators debugging
139+
* "unexpected response" tickets need it on the *error* path too, not
140+
* just on success/passthrough returns.
136141
*/
137-
function unexpectedResponseError(detail: string): ValidationError {
142+
function unexpectedResponseError(detail: string, request_id?: string): ValidationError {
138143
return new ValidationError({
139144
status: 0,
140145
code: "unexpected_response",
141146
title: "Unexpected Response",
142147
detail,
148+
request_id,
143149
});
144150
}
145151

@@ -250,14 +256,13 @@ function requireValidTags(tags: string[] | null | undefined): void {
250256
const ALLOWED_URL_SCHEMES = ["http://", "https://"] as const;
251257

252258
function requireValidTargetUrl(target_url: unknown): void {
253-
// Split the type guard from the scheme check so the error message
254-
// pinpoints the actual failure mode. A non-string `target_url` (a
255-
// number, null, plain object) gets a "must be a string" message
256-
// matching the rest of the validators (requireMaxLength,
257-
// requireValidTags); only an actual string value with a bad scheme
258-
// gets the "must start with http:// or https://" message. Without
259-
// the split, an untyped-JS caller passing `null` would get a
260-
// misleading "scheme" complaint.
259+
// Three checks in priority order: type guard, scheme, length.
260+
// Stop at the first failure so a non-string target_url doesn't
261+
// produce both "must be a string" AND "must be ≤ 2048 characters"
262+
// (the length check on a non-string would also typeof-fail and
263+
// duplicate the type message). The collect-all loop in
264+
// validateCreateInput runs each FIELD's validator independently —
265+
// within target_url itself we want fail-fast.
261266
if (typeof target_url !== "string") {
262267
throw clientValidationError(
263268
`target_url: must be a string (got ${target_url === null ? "null" : typeof target_url})`,
@@ -269,6 +274,11 @@ function requireValidTargetUrl(target_url: unknown): void {
269274
const repr = JSON.stringify(target_url).slice(0, 40);
270275
throw clientValidationError(`target_url: must start with http:// or https:// (got ${repr})`);
271276
}
277+
if (target_url.length > MAX_TARGET_URL) {
278+
throw clientValidationError(
279+
`target_url: must be ${MAX_TARGET_URL} characters or fewer (got ${target_url.length})`,
280+
);
281+
}
272282
}
273283

274284
function validateCreateInput(input: CreateInput): void {
@@ -290,7 +300,6 @@ function validateCreateInput(input: CreateInput): void {
290300
}
291301
};
292302
collect(() => requireValidTargetUrl(input.target_url));
293-
collect(() => requireMaxLength(input.target_url, "target_url", MAX_TARGET_URL));
294303
collect(() => requireMaxLength(input.label, "label", MAX_LABEL));
295304
collect(() => requireMaxLength(input.custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN));
296305
collect(() => requireMaxSessionsInRange(input.max_sessions));
@@ -335,7 +344,14 @@ function validateCreateInput(input: CreateInput): void {
335344
function batchItemResultValidationReason(entry: unknown): string | null {
336345
if (!entry || typeof entry !== "object") return "not an object";
337346
const e = entry as Record<string, unknown>;
338-
if (typeof e.index !== "number") return "missing/invalid 'index' (expected number)";
347+
// `Number.isInteger` rejects NaN, Infinity, and floats; combined
348+
// with `>= 0`, the check rules out pathological proxy bodies that
349+
// would attribute to a useless index. `index` is informational, so
350+
// this isn't a correctness hazard, but tightening the constraint
351+
// matches the same posture as the count integer guards above.
352+
if (!Number.isInteger(e.index) || (e.index as number) < 0) {
353+
return "missing/invalid 'index' (expected non-negative integer)";
354+
}
339355
if (e.success === true) {
340356
if (typeof e.resource_id !== "string") {
341357
return "success-branch missing required field 'resource_id' (expected string)";
@@ -506,6 +522,16 @@ export class QURLClient {
506522
/**
507523
* Maps the API's "qurls" field to "access_tokens" on the SDK type.
508524
* Uses destructuring to avoid mutation and unsafe casts.
525+
*
526+
* **Why `"qurls" in raw` (not `if (raw.qurls)`):** an empty array
527+
* `qurls: []` is truthy as a value but the rename must still happen
528+
* — consumers expect `access_tokens: []` (empty list of tokens), not
529+
* `qurls: []` slipping through. The `in` operator triggers on
530+
* presence regardless of value, so empty / null / undefined `qurls`
531+
* all enter the rename branch and the wire-format key gets stripped.
532+
* Walks the prototype chain by spec, but `response.json()` produces
533+
* plain objects so this is safe in practice. Locked in by the test
534+
* `"preserves empty qurls: [] as access_tokens: []"`.
509535
*/
510536
private mapQurlsField(raw: QURL & { qurls?: AccessToken[] }): QURL {
511537
if (!("qurls" in raw)) return raw;
@@ -554,6 +580,13 @@ export class QURLClient {
554580
const result = envelope.data;
555581
const statusSuffix =
556582
envelope.http_status !== undefined ? ` (HTTP ${envelope.http_status})` : "";
583+
// Thread the server's request_id into the shape-guard error so
584+
// operators debugging "unexpected response" tickets have the
585+
// correlation handle on the error path, mirroring the success-
586+
// path propagation in batchCreate(). The field is optional —
587+
// older API versions without `meta.request_id` simply produce
588+
// an error without it, same as today.
589+
const requestId = envelope.meta?.request_id;
557590

558591
// `Number.isInteger` rejects NaN, Infinity, and floats. Combined
559592
// with `>= 0`, this rules out pathological proxy bodies (e.g.
@@ -570,6 +603,7 @@ export class QURLClient {
570603
) {
571604
throw unexpectedResponseError(
572605
`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}`,
606+
requestId,
573607
);
574608
}
575609

@@ -578,6 +612,7 @@ export class QURLClient {
578612
`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: ` +
579613
`counts/results length mismatch (succeeded=${result.succeeded}, ` +
580614
`failed=${result.failed}, results.length=${result.results.length})`,
615+
requestId,
581616
);
582617
}
583618

@@ -588,6 +623,7 @@ export class QURLClient {
588623
if (reason !== null) {
589624
throw unexpectedResponseError(
590625
`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] ${reason}`,
626+
requestId,
591627
);
592628
}
593629
}
@@ -1206,17 +1242,18 @@ export class QURLClient {
12061242
// TODO: parse HTTP-date format per RFC 7231 §7.1.3 — currently only
12071243
// handles delta-seconds; HTTP-date headers fall through to the
12081244
// exponential-backoff branch in `retryDelay`. Tracked in #61.
1209-
const seconds = parseInt(header, 10);
1210-
if (Number.isNaN(seconds)) {
1211-
// Surface non-numeric Retry-After values via debug so operators
1212-
// see drift if the upstream ever switches formats — without this,
1213-
// the silent fall-through to exponential backoff is invisible.
1245+
//
1246+
// Digit-only pre-check rather than `parseInt` alone: `parseInt("60abc")`
1247+
// returns `60` and would silently honor a malformed header. The
1248+
// strict check makes any deviation observable (debug log) instead.
1249+
const trimmed = header.trim();
1250+
if (!/^\d+$/.test(trimmed)) {
12141251
this.log("Retry-After header was non-numeric, using exponential backoff", {
12151252
header_value: header.slice(0, 100),
12161253
});
12171254
return undefined;
12181255
}
1219-
return seconds;
1256+
return parseInt(trimmed, 10);
12201257
}
12211258

12221259
/**

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,15 @@ export interface UpdateInput {
210210
*
211211
* `expires_in` and `expires_at` are mutually exclusive — provide at most one.
212212
* If neither is specified, the link defaults to 24 hours from now.
213+
*
214+
* **Why this isn't a {@link ExtendInput}-style discriminated union:**
215+
* `ExtendInput` requires *exactly one* of the two fields, which fits
216+
* `T1 | T2` cleanly. `MintInput` allows *at most one* (zero is valid —
217+
* the server applies the 24h default), which would need a three-arm
218+
* union (`{ expires_in } | { expires_at } | { neither }`). The runtime
219+
* check in {@link QURLClient.mintLink} catches the "both provided"
220+
* case at minimal cost; the type-system gain isn't worth the noisier
221+
* type for consumers who just want to omit both and accept the default.
213222
*/
214223
export interface MintInput {
215224
/** Relative duration until expiration (e.g., `"5m"`, `"24h"`, `"7d"`). Mutually exclusive with `expires_at`. */

0 commit comments

Comments
 (0)