Skip to content

Commit 3cb5a30

Browse files
justin-layervclaude
andcommitted
fix: address review — list per-key types, batch sep, hasOwnProperty, request_id in msg
8 items. ### Concerns 1. **Two-level `;` separator in batchCreate aggregated errors.** Inner per-field errors join with `; `, outer per-item errors used `; ` too — the same separator at two levels was ambiguous. Outer separator is now ` | ` so callers can split reliably: `items[0]: a; b | items[2]: c; d`. JSDoc updated. 2. **`list()` per-key type validation.** Was permissive (any string-or-number accepted for any key). Per the OpenAPI spec only `limit` is numeric; `cursor`/`status`/`q`/`sort`/dates are strings. Tightened to per-key `expectedType` so `cursor: 42` is rejected instead of silently coerced to `"42"`. Updated existing test; added new test for numeric values on string-typed fields. 3. **`mapQurlsField` `Object.prototype.hasOwnProperty.call`.** Replaced the bare `in` operator (walks prototype chain by spec) with `hasOwnProperty.call`. `response.json()` produces plain objects today, but defends against future consumers feeding pre-parsed bodies from arbitrary sources. 5. **`AbortSignal.timeout` per-attempt budget documented.** Added a block comment explaining the deliberate per-attempt-not-total semantics (so a slow-then-failed attempt doesn't poison the next retry's deadline) plus the worst-case total bound. 6. **`Quota.usage.active_qurls_percent` field-level JSDoc.** Documented the `null = unlimited plan` semantic on the field itself so IDE hover catches it without forcing readers back to the PR description. ### Nits - **`request_id` in shape-guard error message string.** Embedded `[request_id=...]` in the detail message in addition to `.requestId` — a stack trace pasted into a support ticket now carries the correlation handle without a follow-up round-trip. Existing test extended to assert the new suffix. ### Coverage - **`qurls: null` with `access_tokens` already present.** Exercises the `qurls && ...` truthy short-circuit branches in mapQurlsField. Defends against a future refactor that flips presence-vs-truthy and surfaces `qurls: null` on a typed QURL. ### No action - **#4 `requireString` helper extraction.** Reviewer marked optional ("tiny refactor"). Skipping — the duplicated typeof guard appears in three small spots and pulling it out adds an indirection layer. - **HTTP-date `Retry-After` support.** Already filed as #61. - **`extend()` narrower-than-`update()` type relationship.** Reviewer noted as informational. Already documented in extend()'s JSDoc. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent f3795dd commit 3cb5a30

3 files changed

Lines changed: 133 additions & 30 deletions

File tree

src/client.test.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,48 @@ describe("QURLClient", () => {
314314
expect(result.access_tokens).toBeUndefined();
315315
});
316316

317+
it("handles qurls: null with access_tokens already present (truthy short-circuit)", async () => {
318+
// Exercises the `qurls && ...` branches in mapQurlsField. With
319+
// qurls being null (not absent), the rename branch is entered
320+
// (presence check passes), but the truthy short-circuit means
321+
// we keep access_tokens as-is and drop the wire-format key.
322+
// Defends against a future refactor that flips presence-vs-truthy
323+
// and accidentally surfaces qurls: null on a typed QURL.
324+
const accessTokens = [
325+
{
326+
qurl_id: "at_existing",
327+
status: "active",
328+
one_time_use: false,
329+
max_sessions: 5,
330+
session_duration: 3600,
331+
use_count: 0,
332+
created_at: "2026-03-10T10:00:00Z",
333+
expires_at: "2026-04-10T10:00:00Z",
334+
},
335+
];
336+
const fetch = mockFetch({
337+
status: 200,
338+
body: {
339+
data: {
340+
resource_id: "r_dual",
341+
target_url: "https://example.com",
342+
status: "active",
343+
qurl_count: 1,
344+
created_at: "2026-03-10T10:00:00Z",
345+
qurls: null,
346+
access_tokens: accessTokens,
347+
},
348+
},
349+
});
350+
351+
const client = createClient(fetch);
352+
const result = await client.get("r_dual");
353+
354+
expect(result.access_tokens).toEqual(accessTokens);
355+
// Wire-format key stripped from consumer-facing object.
356+
expect((result as unknown as { qurls?: unknown }).qurls).toBeUndefined();
357+
});
358+
317359
it("preserves empty qurls: [] as access_tokens: []", async () => {
318360
// Defense against a future refactor flipping the truthiness check
319361
// in mapQurlsField from `"qurls" in raw` to `if (raw.qurls)`. An
@@ -3324,10 +3366,10 @@ describe("QURLClient", () => {
33243366
});
33253367
const client = createClient(fetch);
33263368

3327-
for (const [key, value] of [
3328-
["q", ["foo", "bar"]],
3329-
["sort", { fieldName: "created_at" }],
3330-
["cursor", true],
3369+
for (const [key, value, expectedType] of [
3370+
["q", ["foo", "bar"], "string"],
3371+
["sort", { fieldName: "created_at" }, "string"],
3372+
["cursor", true, "string"],
33313373
] as const) {
33323374
const error = await client
33333375
.list({ [key]: value } as unknown as Parameters<typeof client.list>[0])
@@ -3336,7 +3378,34 @@ describe("QURLClient", () => {
33363378
expect((error as ValidationError).code).toBe("client_validation");
33373379
const detail = (error as ValidationError).detail;
33383380
expect(detail).toContain(key);
3339-
expect(detail).toContain("must be a string or number");
3381+
// Per-key type enforcement: cursor / q / sort are strings,
3382+
// not numbers — `cursor: 42` would silently coerce without
3383+
// this guard.
3384+
expect(detail).toContain(`must be a ${expectedType}`);
3385+
}
3386+
expect(fetch).not.toHaveBeenCalled();
3387+
});
3388+
3389+
it("list rejects numeric values for string-typed filter fields (e.g. cursor: 42)", async () => {
3390+
// Per-key type enforcement: only `limit` is numeric per the
3391+
// OpenAPI spec. `cursor: 42`, `status: 1`, etc. would silently
3392+
// coerce to "42" / "1" through the previous broad
3393+
// string-or-number guard. Now they fail fast with a per-key
3394+
// ValidationError pointing at the expected type.
3395+
const fetch = mockFetch({
3396+
status: 200,
3397+
body: { data: [], meta: { has_more: false } },
3398+
});
3399+
const client = createClient(fetch);
3400+
3401+
for (const key of ["cursor", "status", "q", "sort"] as const) {
3402+
const error = await client
3403+
.list({ [key]: 42 } as unknown as Parameters<typeof client.list>[0])
3404+
.catch((e: unknown) => e as ValidationError);
3405+
expect(error).toBeInstanceOf(ValidationError);
3406+
const detail = (error as ValidationError).detail;
3407+
expect(detail).toContain(key);
3408+
expect(detail).toContain("must be a string");
33403409
}
33413410
expect(fetch).not.toHaveBeenCalled();
33423411
});
@@ -4098,6 +4167,10 @@ describe("QURLClient", () => {
40984167
expect(error).toBeInstanceOf(ValidationError);
40994168
expect((error as ValidationError).code).toBe("unexpected_response");
41004169
expect((error as ValidationError).requestId).toBe("req_shape_guard_correlation");
4170+
// request_id also lands in the message string itself so a stack
4171+
// trace pasted into a support ticket carries the correlation
4172+
// handle without a follow-up round-trip.
4173+
expect((error as ValidationError).detail).toContain("[request_id=req_shape_guard_correlation]");
41014174
});
41024175

41034176
it("batch create surfaces ValidationError on unexpected 400 response shape", async () => {

src/client.ts

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -523,18 +523,21 @@ export class QURLClient {
523523
* Maps the API's "qurls" field to "access_tokens" on the SDK type.
524524
* Uses destructuring to avoid mutation and unsafe casts.
525525
*
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`
526+
* **Why `hasOwnProperty.call` (not `if (raw.qurls)`):** an empty
527+
* array `qurls: []` is truthy as a value but the rename must still
528+
* happen — consumers expect `access_tokens: []` (empty list of
529+
* tokens), not `qurls: []` slipping through. The presence check
530+
* triggers regardless of value, so empty / null / undefined `qurls`
531531
* 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
532+
* Belt-and-suspenders form: `Object.prototype.hasOwnProperty.call`
533+
* confines the check to own-properties (the bare `in` operator walks
534+
* the prototype chain). `response.json()` already produces plain
535+
* objects, but if the SDK ever consumes pre-parsed bodies from
536+
* arbitrary sources, this stays safe. Locked in by the test
534537
* `"preserves empty qurls: [] as access_tokens: []"`.
535538
*/
536539
private mapQurlsField(raw: QURL & { qurls?: AccessToken[] }): QURL {
537-
if (!("qurls" in raw)) return raw;
540+
if (!Object.prototype.hasOwnProperty.call(raw, "qurls")) return raw;
538541
const { qurls, ...rest } = raw;
539542
if (qurls && !rest.access_tokens) {
540543
return { ...rest, access_tokens: qurls };
@@ -585,8 +588,12 @@ export class QURLClient {
585588
// correlation handle on the error path, mirroring the success-
586589
// path propagation in batchCreate(). The field is optional —
587590
// older API versions without `meta.request_id` simply produce
588-
// an error without it, same as today.
591+
// an error without it, same as today. Embedded in BOTH the
592+
// .requestId property AND the message string so a stack trace
593+
// pasted into a support ticket carries the correlation handle
594+
// without a follow-up round-trip.
589595
const requestId = envelope.meta?.request_id;
596+
const requestIdSuffix = requestId !== undefined ? ` [request_id=${requestId}]` : "";
590597

591598
// `Number.isInteger` rejects NaN, Infinity, and floats. Combined
592599
// with `>= 0`, this rules out pathological proxy bodies (e.g.
@@ -602,7 +609,7 @@ export class QURLClient {
602609
!Array.isArray(result.results)
603610
) {
604611
throw unexpectedResponseError(
605-
`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}`,
612+
`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}${requestIdSuffix}`,
606613
requestId,
607614
);
608615
}
@@ -611,7 +618,7 @@ export class QURLClient {
611618
throw unexpectedResponseError(
612619
`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: ` +
613620
`counts/results length mismatch (succeeded=${result.succeeded}, ` +
614-
`failed=${result.failed}, results.length=${result.results.length})`,
621+
`failed=${result.failed}, results.length=${result.results.length})${requestIdSuffix}`,
615622
requestId,
616623
);
617624
}
@@ -622,7 +629,7 @@ export class QURLClient {
622629
const reason = batchItemResultValidationReason(result.results[i]);
623630
if (reason !== null) {
624631
throw unexpectedResponseError(
625-
`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] ${reason}`,
632+
`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] ${reason}${requestIdSuffix}`,
626633
requestId,
627634
);
628635
}
@@ -659,8 +666,11 @@ export class QURLClient {
659666
* **Client-side validation:** All items are validated before any network
660667
* request is made (target_url scheme, label length, max_sessions range,
661668
* tag constraints). All failures are collected into a single
662-
* `ValidationError` with per-index attribution (`"items[0]: ...; items[3]: ..."`),
663-
* so callers see every problem in one throw instead of fix-re-run-repeat.
669+
* `ValidationError` with per-index attribution
670+
* (`"items[0]: ... | items[3]: ..."`) — items are separated by ` | ` and
671+
* field-level errors within an item by `; ` so callers can split the
672+
* detail message reliably. Every problem surfaces in one throw instead
673+
* of fix-re-run-repeat.
664674
*
665675
* Throws `ValidationError` client-side (`status: 0`, `code: "client_validation"`)
666676
* when `items` is empty or exceeds 100, or when the HTTP 400 response body
@@ -722,7 +732,13 @@ export class QURLClient {
722732
}
723733
}
724734
if (perItemErrors.length > 0) {
725-
throw clientValidationError(`batchCreate: ${perItemErrors.join("; ")}`);
735+
// Outer separator differs from `validateCreateInput`'s inner
736+
// `"; "` so callers can split the message reliably:
737+
// `items[0]: a; b | items[2]: c; d`
738+
// Without this asymmetry, the same `; ` at both levels makes
739+
// it ambiguous which separator is "next item" vs "next field
740+
// within item". Mirrors the JSDoc example shape.
741+
throw clientValidationError(`batchCreate: ${perItemErrors.join(" | ")}`);
726742
}
727743
// 400 carries per-item errors (see rawRequest JSDoc). Use rawRequest
728744
// directly (not `this.request`) so we can read `meta.request_id`
@@ -801,23 +817,23 @@ export class QURLClient {
801817
// typing can't prevent callers from spreading untyped objects with extra
802818
// properties, and String(value) on an unexpected array/object would emit
803819
// "[object Object]" as a query param.
820+
// Per the OpenAPI spec, only `limit` is numeric; every other
821+
// filter field is a string. Enforce per-key type expectations
822+
// so untyped-JS callers passing `cursor: 42` (or worse,
823+
// `q: ["foo", "bar"]`) get a structured ValidationError instead
824+
// of silently coercing to `"42"` / `"foo,bar"`.
825+
const NUMERIC_LIST_KEYS: ReadonlySet<keyof ListInput> = new Set(["limit"]);
804826
for (const key of LIST_PARAM_KEYS) {
805827
const value = input[key];
806828
// Drop null/undefined and empty strings — an empty string from
807829
// an untyped JS caller would otherwise produce `?status=&q=`
808830
// garbage that the API might interpret as an explicit empty
809-
// filter. Non-zero numerics are preserved (serialize as their
810-
// string form); `limit` is validated separately above.
831+
// filter. `limit` is validated separately above.
811832
if (value === null || value === undefined || value === "") continue;
812-
// Reject non-string/number values explicitly. `String(["a","b"])`
813-
// produces `"a,b"` (which accidentally matches `status`'s CSV
814-
// form but means nothing for `q` / `sort` / dates), and
815-
// `String({})` produces `"[object Object]"` — both leak garbage
816-
// through the type system from untyped-JS callers. Surface a
817-
// structured ValidationError instead.
818-
if (typeof value !== "string" && typeof value !== "number") {
833+
const expectedType = NUMERIC_LIST_KEYS.has(key) ? "number" : "string";
834+
if (typeof value !== expectedType) {
819835
throw clientValidationError(
820-
`${key}: must be a string or number (got ${value === null ? "null" : typeof value})`,
836+
`${key}: must be a ${expectedType} (got ${value === null ? "null" : typeof value})`,
821837
);
822838
}
823839
params.set(key, String(value));
@@ -1101,6 +1117,14 @@ export class QURLClient {
11011117

11021118
let response: Response;
11031119
try {
1120+
// `timeout` is intentionally a per-attempt budget, not a total-
1121+
// request budget. With maxRetries=3 and timeout=30s, a slow
1122+
// upstream + retries can run up to ~120s total. The per-
1123+
// attempt cap matches `fetch()` semantics elsewhere and lets
1124+
// each retry get a fresh window — a half-completed slow
1125+
// attempt shouldn't poison the next try's deadline. Total
1126+
// request bound: roughly `timeout * (maxRetries + 1) +
1127+
// sum(retryDelay)`.
11041128
response = await this.fetchFn(url, {
11051129
method,
11061130
headers,

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,12 @@ export interface Quota {
295295
usage?: {
296296
qurls_created?: number;
297297
active_qurls?: number;
298+
/**
299+
* Active qURLs as a percentage of the plan's `max_active_qurls`,
300+
* or `null` when the plan is unlimited (no denominator). Callers
301+
* doing arithmetic must guard the null case — `usage.active_qurls_percent * 0.01`
302+
* yields `NaN` if the plan is unlimited.
303+
*/
298304
active_qurls_percent?: number | null;
299305
total_accesses?: number;
300306
};

0 commit comments

Comments
 (0)