Skip to content

Make SDK response types honest, harden commit retries#1

Merged
masterleopold merged 2 commits into
mainfrom
fix/honest-sdk-types
Jul 11, 2026
Merged

Make SDK response types honest, harden commit retries#1
masterleopold merged 2 commits into
mainfrom
fix/honest-sdk-types

Conversation

@masterleopold

@masterleopold masterleopold commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Audit of the published `@meter-mcp/*` SDK against the server contract found response types that claimed shapes the server never returns, silently breaking consumers. This branch makes them honest and hardens the commit path. Full gate green: build, 25 tests, publint, attw, `verify:openapi`, examples typecheck.

Type accuracy (silent consumer bugs)

  • `upsertCustomer` returned `apiKey: string`, but the server omits it on an idempotent re-upsert of an existing customer — now `apiKey?: string`.
  • `MeterAuthorizeResponse.quote` intersected the request-shaped `MeterToolCall` (requires `customerLocalId`, forbids 0 credits); replaced with the server's actual quote shape.
  • `reservation?` on authorize/commit/release is now `| null` (the server returns null without a requestId).
  • A type-level regression test locks these so a future revert fails the build, not just tests.

Correctness

  • `meterToolCall`/`withUsage` now retry a failed commit up to 3× with backoff (network + 5xx only; 4xx aborts) and, on exhaustion, throw the new `MeterCommitFailedError` carrying the tool result + requestId so the caller can re-commit (commit is idempotent server-side).
  • Request timeout now covers the response body read (both clients), not just the headers.
  • 429 `retry-after` is exposed as `MeterPublicApiError.retryAfterSeconds`.
  • Anthropic adapter: `cache_creation_input_tokens` kept in the billable input count (Anthropic's `input_tokens` excludes cache tokens) instead of being folded into the cached bucket.
  • Example webhook route returns 400 on a bad signature instead of an unhandled 500.

A changeset is included (sdk minor + adapters patch). No publish.

🤖 Generated with Claude Code

https://claude.ai/code/session_011ydudzw4rEgoBK3MaRmrub

Summary by Sourcery

Align SDK response types with actual server behavior and improve reliability of usage commit flows.

New Features:

  • Expose retry-after seconds on 429 responses via MeterPublicApiError.
  • Introduce MeterCommitFailedError to surface failed commits along with the tool result and requestId so callers can re-commit.

Bug Fixes:

  • Treat bad webhook signatures as explicit 400 responses instead of unhandled 500s.
  • Ensure request timeouts cover response body reads for both public API and onboarding clients.

Enhancements:

  • Relax SDK response typings for authorize, commit, release, and upsertCustomer to match server-returned shapes, including optional apiKey and nullable reservations.
  • Correct MeterAuthorizeResponse.quote typing to reflect the rated quote shape and allow zero-credit quotes.
  • Add commit retry with exponential backoff in meterToolCall/withUsage for transient network and 5xx errors, avoiding retries on 4xx responses.
  • Clarify Anthropic adapter cache token handling and lock pricing behavior with tests.
  • Add type-level regression tests to prevent future divergence between SDK response types and server contracts.

Tests:

  • Extend client and adapter tests to cover commit retry behavior, error propagation, Anthropic pricing, and the adjusted response typings.

Chores:

  • Add a changeset for a minor SDK release and patch adapters release without publishing.

masterleopold and others added 2 commits July 11, 2026 19:30
Response types now match what the server actually returns: upsertCustomer
apiKey is optional (only present on first creation), the authorize quote
carries no customerLocalId and permits zero credits, and reservation is
nullable on authorize/commit/release. meterToolCall retries transient
commit failures (network/5xx, never 4xx) because commit is idempotent on
requestId; exhausted retries throw MeterCommitFailedError carrying the
tool result and requestId so callers can re-commit instead of losing an
expensive result. Anthropic cache_creation_input_tokens stays in the
billable input bucket (no distinct field exists) and the bucketing is
locked with priced adapter tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ydudzw4rEgoBK3MaRmrub
The example Next.js handler let verifyMeterWebhookSignature throw on a bad or
forged signature, surfacing as an unhandled 500. Catch it and reject with 400 so
copy-pasted deployments fail closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ydudzw4rEgoBK3MaRmrub
@sourcery-ai

sourcery-ai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

Aligns SDK response types with actual server behavior, adds type-level regression tests, introduces robust commit retry semantics with a new error type, extends HTTP timeout to cover body reads, exposes 429 retry-after metadata, corrects Anthropic adapter token accounting, and hardens the example webhook error handling, with corresponding tests and a changeset.

Sequence diagram for meterToolCall commit retry and MeterCommitFailedError

sequenceDiagram
  actor Caller
  participant MeterPublicApiClient
  participant ExternalTool
  participant MeterServer

  Caller->>MeterPublicApiClient: meterToolCall(usage, options)
  MeterPublicApiClient->>ExternalTool: execute tool
  ExternalTool-->>MeterPublicApiClient: result
  MeterPublicApiClient->>MeterServer: commit(commitInput)
  loop up to COMMIT_MAX_ATTEMPTS (3)
    alt commit succeeds
      MeterServer-->>MeterPublicApiClient: MeterCommitResponse
      MeterPublicApiClient-->>Caller: result
    else commit fails (network or 5xx)
      MeterServer-->>MeterPublicApiClient: error
      MeterPublicApiClient->>MeterPublicApiClient: sleep(COMMIT_RETRY_BASE_DELAY_MS * 2**n)
    else commit fails with 4xx
      MeterServer-->>MeterPublicApiClient: MeterPublicApiError(status<500)
      MeterPublicApiClient->>Caller: throw MeterCommitFailedError(result, requestId, error)
    end
  end
  Note over MeterPublicApiClient,MeterServer: commit is idempotent server-side keyed on requestId
Loading

Sequence diagram for webhook POST route with signature validation

sequenceDiagram
  actor WebhookSender
  participant RouteHandler as POST
  participant SDK as verifyMeterWebhookSignature
  participant Processor as processEventIdempotently

  WebhookSender->>RouteHandler: POST request
  RouteHandler->>RouteHandler: payload = request.text()
  RouteHandler->>SDK: verifyMeterWebhookSignature({payload, signature, secret})
  alt signature valid
    SDK-->>RouteHandler: ok
    RouteHandler->>RouteHandler: event = JSON.parse(payload)
    RouteHandler->>Processor: processEventIdempotently(event)
    Processor-->>RouteHandler: done
    RouteHandler-->>WebhookSender: Response.json({received:true})
  else signature invalid
    SDK-->>RouteHandler: throw
    RouteHandler-->>WebhookSender: Response.json({error:"invalid_signature"}, {status:400})
  end
Loading

File-Level Changes

Change Details Files
Make SDK response types match the server contract and add type-level regression locks.
  • Update MeterAuthorizeResponse.quote to use the rated quote shape instead of intersecting with request-shaped MeterToolCall.
  • Allow reservation to be null on authorize, commit, and release responses.
  • Make upsertCustomer return an optional apiKey instead of a required one.
  • Add response-types.test.ts that uses satisfies and type-only fixtures to ensure these shapes stay honest.
packages/sdk/src/client.ts
packages/sdk/test/response-types.test.ts
Introduce robust commit retry behavior for meterToolCall/withUsage and a dedicated error type for exhausted retries.
  • Add MeterCommitFailedError to carry the tool result, requestId, and cause when commit retries are exhausted.
  • Implement commit retry loop with up to 3 attempts and exponential backoff, retrying only on network/5xx errors and aborting on 4xx.
  • Ensure successful commits return the protected work result and exhausted retries throw MeterCommitFailedError preserving requestId.
  • Add tests covering successful retry, exhausted retries with preserved reservation, and non-retrying 4xx responses.
packages/sdk/src/client.ts
packages/sdk/test/client.test.ts
Tighten HTTP timeout behavior and expose 429 retry-after metadata from the public API client.
  • Extend request timeout to cover response body reading for both MeterPublicApiClient and MeterOnboardingClient.
  • Parse retry-after headers on non-ok responses and surface them as retryAfterSeconds on MeterPublicApiError.
  • Adjust request implementations to read res.text() under the active timeout before clearing it.
packages/sdk/src/client.ts
Correct Anthropic adapter usage accounting and lock behavior with priced tests.
  • Clarify that Anthropic cache_creation_input_tokens remain in the billable input bucket rather than the cached bucket.
  • Ensure cache_read_input_tokens map to the cached bucket, keeping cache_creation tokens in inputTokens.
  • Extend adapter tests to include pricing configuration and assert correct costMicrousd and token bucketing.
packages/adapters/src/index.ts
packages/adapters/test/index.test.ts
Harden the example webhook route to return a 400 on bad signatures instead of 500.
  • Wrap verifyMeterWebhookSignature in a try/catch and return a 400 invalid_signature response on verification failure.
  • Leave successful verification path unchanged, still parsing the payload and processing the event idempotently.
examples/meter-sdk/webhook-route.ts
Document the SDK and adapter changes with a changeset for versioning.
  • Add a changeset marking @meter-mcp/sdk for a minor bump and @meter-mcp/adapters for a patch bump.
  • Summarize the type honesty, commit retry behavior, Anthropic adapter semantics, and tests in the changeset description.
.changeset/honest-types-commit-retry.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 5 issues, and left some high level feedback:

  • MeterCommitFailedError assumes a non-null requestId but meterToolCall passes usage.requestId directly, so if callers omit requestId you’ll end up throwing an error with an undefined identifier despite the type, which might be better handled via a runtime assertion or by making requestId optional in the error’s shape.
  • The retry-after parsing in MeterPublicApiClient.request treats the header as a numeric seconds value only, but HTTP allows a date format as well; consider handling both forms or documenting that only delta-seconds headers are respected.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- MeterCommitFailedError assumes a non-null requestId but meterToolCall passes usage.requestId directly, so if callers omit requestId you’ll end up throwing an error with an undefined identifier despite the type, which might be better handled via a runtime assertion or by making requestId optional in the error’s shape.
- The retry-after parsing in MeterPublicApiClient.request treats the header as a numeric seconds value only, but HTTP allows a date format as well; consider handling both forms or documenting that only delta-seconds headers are respected.

## Individual Comments

### Comment 1
<location path="packages/sdk/src/client.ts" line_range="878-887" />
<code_context>
       }
     }
     if (!res.ok) {
+      const retryAfterRaw = res.headers.get("retry-after");
+      const retryAfterSeconds =
+        retryAfterRaw && Number.isFinite(Number(retryAfterRaw))
+          ? Number(retryAfterRaw)
+          : undefined;
       throw new MeterPublicApiError(
         res.status,
         path,
         body,
-        res.headers.get("x-request-id") ?? undefined
+        res.headers.get("x-request-id") ?? undefined,
+        retryAfterSeconds
       );
     }
</code_context>
<issue_to_address>
**suggestion:** The `retry-after` parsing only supports numeric seconds and silently ignores HTTP-date values.

The implementation only handles numeric `Retry-After` values and discards valid HTTP-date headers, so callers miss useful backoff information when servers use the date form. To better align with the spec and improve interoperability, consider parsing non-numeric values with `Date.parse` and converting them to seconds relative to `Date.now()`, while still treating malformed headers as `undefined`.

Suggested implementation:

```typescript
    }
    if (!res.ok) {

```

```typescript
    if (!res.ok) {
      const retryAfterRaw = res.headers.get("retry-after");
      let retryAfterSeconds: number | undefined;

      if (retryAfterRaw) {
        const numericValue = Number(retryAfterRaw);
        if (Number.isFinite(numericValue)) {
          // Numeric seconds form: Retry-After: 120
          retryAfterSeconds = numericValue;
        } else {
          // HTTP-date form: Retry-After: Wed, 21 Oct 2015 07:28:00 GMT
          const parsedDateMs = Date.parse(retryAfterRaw);
          if (!Number.isNaN(parsedDateMs)) {
            const diffMs = parsedDateMs - Date.now();
            if (diffMs > 0) {
              retryAfterSeconds = diffMs / 1000;
            }
            // If diffMs <= 0, the date is in the past; treat as no backoff (undefined)
          }
          // Malformed HTTP-date leaves retryAfterSeconds as undefined
        }
      }

      throw new MeterPublicApiError(
        res.status,
        path,
        body,
        res.headers.get("x-request-id") ?? undefined,
        retryAfterSeconds
      );
    }

```

1. Ensure the `MeterPublicApiError` constructor accepts the new `retryAfterSeconds?: number` parameter and stores/exposes it appropriately.
2. Callers that consume `retryAfterSeconds` should be prepared for fractional seconds (e.g., 1.5) and for `undefined` when the header is absent or malformed, or when the HTTP-date is already in the past.
</issue_to_address>

### Comment 2
<location path="examples/meter-sdk/webhook-route.ts" line_range="7-13" />
<code_context>
-  });
+  // A bad/forged signature must be a 400, not an unhandled 500: verifyMeterWebhookSignature
+  // throws on mismatch, so catch it and reject explicitly before trusting the body.
+  try {
+    await verifyMeterWebhookSignature({
+      payload,
+      signature: request.headers.get("x-meter-signature") ?? "",
+      secret: process.env.METER_WEBHOOK_SECRET!,
+    });
+  } catch {
+    return Response.json({ error: "invalid_signature" }, { status: 400 });
+  }
</code_context>
<issue_to_address>
**issue (bug_risk):** Catching all errors from `verifyMeterWebhookSignature` and mapping them to a 400 may hide server-side issues.

This bare `catch` turns all failures from `verifyMeterWebhookSignature`—including runtime/configuration errors—into a 400 `invalid_signature`, which both obscures real server problems and misleads clients. Prefer catching only the specific error that indicates a bad signature, and allow other errors to surface as 500s (with appropriate logging) so genuine infrastructure issues aren’t masked as client errors.
</issue_to_address>

### Comment 3
<location path="packages/sdk/test/client.test.ts" line_range="97-95" />
<code_context>
   assert.equal(calls[1]?.body.reason, "upstream failed");
 });

+test("meterToolCall retries a transient commit failure and returns the result", async () => {
+  const calls: string[] = [];
+  const client = new MeterPublicApiClient({
+    baseUrl: "https://meter.example",
+    serviceId: "service_1",
+    serviceApiKey: "secret",
+    fetchImpl: async (input) => {
+      const path = new URL(String(input)).pathname;
+      calls.push(path);
+      if (path === "/api/v1/meter/commit" && calls.filter((p) => p === path).length === 1) {
+        return Response.json({ error: "internal" }, { status: 503 });
+      }
+      return Response.json({});
+    },
+  });
+
+  const result = await client.meterToolCall(
</code_context>
<issue_to_address>
**suggestion (testing):** Add parallel tests for `withUsage` to cover the new commit retry and `MeterCommitFailedError` behavior

The new `meterToolCall` tests cover transient commit failures, 4xx aborts, and `requestId`/result preservation, but `withUsage` now shares this retry logic and error behavior without any targeted tests. Please add at least two `withUsage` tests: one where a transient 5xx commit eventually succeeds (asserting commit call count and returned result), and one where all commit attempts fail (asserting `MeterCommitFailedError` with the correct `result`/`requestId` and that reservations are not released). This will help prevent regressions in the `withUsage` path.

Suggested implementation:

```typescript
test("meterToolCall retries a transient commit failure and returns the result", async () => {
  const calls: string[] = [];
  const client = new MeterPublicApiClient({
    baseUrl: "https://meter.example",
    serviceId: "service_1",
    serviceApiKey: "secret",
    fetchImpl: async (input) => {
      const path = new URL(String(input)).pathname;
      calls.push(path);
      if (path === "/api/v1/meter/commit" && calls.filter((p) => p === path).length === 1) {
        return Response.json({ error: "internal" }, { status: 503 });
      }
      return Response.json({});
    },
  });

  const result = await client.meterToolCall(
    { customerLocalId: "customer_1", tool: "search", credits: 3 },
    () => "expensive result"
  );
  assert.equal(result, "expensive result");
  assert.deepEqual(calls, [
    "/api/v1/meter/authorize",
    "/api/v1/meter/commit",
    "/api/v1/meter/commit",
  ]);
});

test("withUsage retries a transient commit failure and returns the result", async () => {
  const calls: string[] = [];
  const client = new MeterPublicApiClient({
    baseUrl: "https://meter.example",
    serviceId: "service_1",
    serviceApiKey: "secret",
    fetchImpl: async (input, init) => {
      const url = new URL(String(input));
      const path = url.pathname;
      calls.push(path);

      // Simulate an authorize call that returns a requestId
      if (path === "/api/v1/meter/authorize") {
        return Response.json({ requestId: "req_1" });
      }

      // First commit attempt fails with a transient 5xx error, second succeeds
      if (path === "/api/v1/meter/commit") {
        const commitAttempts = calls.filter((p) => p === path).length;
        if (commitAttempts === 1) {
          return Response.json({ error: "internal" }, { status: 503 });
        }
        return Response.json({ requestId: "req_1" });
      }

      return Response.json({});
    },
  });

  const result = await client.withUsage(
    { customerLocalId: "customer_1", credits: 3 },
    async () => {
      return "expensive result";
    }
  );

  assert.equal(result, "expensive result");
  assert.deepEqual(calls, [
    "/api/v1/meter/authorize",
    "/api/v1/meter/commit",
    "/api/v1/meter/commit",
  ]);
});

test("withUsage throws MeterCommitFailedError when all commit attempts fail and does not release reservations", async () => {
  const calls: string[] = [];
  const client = new MeterPublicApiClient({
    baseUrl: "https://meter.example",
    serviceId: "service_1",
    serviceApiKey: "secret",
    fetchImpl: async (input, init) => {
      const url = new URL(String(input));
      const path = url.pathname;
      calls.push(path);

      if (path === "/api/v1/meter/authorize") {
        // Return a requestId that should be surfaced on the error
        return Response.json({ requestId: "req_2" });
      }

      if (path === "/api/v1/meter/commit") {
        // All commit attempts fail with a transient 5xx error
        return Response.json({ error: "internal" }, { status: 503 });
      }

      // There should be no release calls on commit failure
      if (path === "/api/v1/meter/release") {
        return Response.json({});
      }

      return Response.json({});
    },
  });

  let caught: unknown;
  try {
    await client.withUsage(
      { customerLocalId: "customer_2", credits: 5 },
      async () => {
        return "failed result";
      }
    );
  } catch (err) {
    caught = err;
  }

  assert.ok(caught instanceof MeterCommitFailedError);
  const error = caught as MeterCommitFailedError;
  assert.equal(error.requestId, "req_2");
  assert.equal(error.result, "failed result");

  // Authorize happens once, commit is retried, and no release is performed
  assert.ok(calls.includes("/api/v1/meter/authorize"));
  const commitCalls = calls.filter((p) => p === "/api/v1/meter/commit").length;
  assert.ok(commitCalls >= 2);
  assert.ok(!calls.includes("/api/v1/meter/release"));
});

```

The `withUsage` tests above assume the following based on typical SDK patterns and the new `meterToolCall` behavior:
1. `MeterPublicApiClient.withUsage` accepts `(options, fn)` where `fn` returns the "result" that should be preserved on `MeterCommitFailedError`. Adjust the call signature if your actual `withUsage` API differs (e.g., if it receives a usage context argument).
2. The commit retry count is asserted as `>= 2` in the failure test to avoid coupling to an exact retry limit. If your implementation has a known fixed retry count, you may tighten this assertion to an exact number and/or assert the full `calls` array contents.
3. The error shape is assumed to be `MeterCommitFailedError` with `requestId` and `result` properties. If your error type exposes these differently (e.g., `error.data.requestId` or `error.commit.requestId`), update the assertions accordingly.
4. If `withUsage` performs additional API calls (for example, a separate reservation or usage endpoint before commit), you may want to extend the `calls` assertions to cover those paths and ensure they behave correctly under commit failure scenarios.
</issue_to_address>

### Comment 4
<location path="packages/sdk/test/client.test.ts" line_range="187-99" />
<code_context>
+  assert.deepEqual(calls, ["/api/v1/meter/authorize", "/api/v1/meter/commit"]);
+});
+
 test("HTTP errors expose status, response body, path, and request ID", async () => {
   const client = new MeterPublicApiClient({
     baseUrl: "https://meter.example",
</code_context>
<issue_to_address>
**suggestion (testing):** Extend HTTP error tests to assert `retryAfterSeconds` on 429 responses

Since `MeterPublicApiError` now exposes `retryAfterSeconds` from the `retry-after` header, please extend this test (or add a new one) to cover a 429 response with a numeric `retry-after` and assert the parsed `retryAfterSeconds` value. Also consider a case with a non-numeric `retry-after` to confirm `retryAfterSeconds` remains `undefined` when parsing fails, so backoff behavior is properly validated.

Suggested implementation:

```typescript
  await assert.rejects(
    client.meterToolCall({ customerLocalId: "customer_1", tool: "search" }, () => "ok"),
    MeterCommitFailedError
  );
  assert.deepEqual(calls, ["/api/v1/meter/authorize", "/api/v1/meter/commit"]);
});

// HTTP error tests

test("HTTP errors expose status, response body, path, request ID, and retryAfterSeconds for numeric retry-after on 429", async () => {
  const calls: string[] = [];

  const client = new MeterPublicApiClient({
    baseUrl: "https://meter.example",
    serviceId: "service_1",
    serviceApiKey: "secret",
    fetchImpl: async (input) => {
      const url = new URL(String(input));
      calls.push(url.pathname);

      // Return a 429 with numeric retry-after and request ID headers
      return new Response(
        JSON.stringify({
          error: "rate_limit",
          message: "Too many requests",
        }),
        {
          status: 429,
          headers: {
            "retry-after": "10",
            "x-request-id": "req_429_numeric",
          },
        }
      );
    },
  });

  const error = await assert.rejects(
    client.meterToolCall({ customerLocalId: "customer_1", tool: "search" }, () => "ok"),
    (err: unknown) => err instanceof MeterPublicApiError
  );

  assert.ok(error instanceof MeterPublicApiError);
  assert.strictEqual(error.status, 429);
  assert.strictEqual(error.retryAfterSeconds, 10);
  assert.strictEqual(error.requestId, "req_429_numeric");
  assert.strictEqual(error.path, "/api/v1/meter/commit");
  assert.deepEqual(calls, ["/api/v1/meter/authorize", "/api/v1/meter/commit"]);
});

test("HTTP 429 with non-numeric retry-after leaves retryAfterSeconds undefined", async () => {
  const calls: string[] = [];

  const client = new MeterPublicApiClient({
    baseUrl: "https://meter.example",
    serviceId: "service_1",
    serviceApiKey: "secret",
    fetchImpl: async (input) => {
      const url = new URL(String(input));
      calls.push(url.pathname);

      // Return a 429 with non-numeric retry-after; parsing should fail
      return new Response(
        JSON.stringify({
          error: "rate_limit",
          message: "Too many requests",
        }),
        {
          status: 429,
          headers: {
            "retry-after": "not-a-number",
            "x-request-id": "req_429_non_numeric",
          },
        }
      );
    },
  });

  const error = await assert.rejects(
    client.meterToolCall({ customerLocalId: "customer_1", tool: "search" }, () => "ok"),
    (err: unknown) => err instanceof MeterPublicApiError
  );

  assert.ok(error instanceof MeterPublicApiError);
  assert.strictEqual(error.status, 429);
  assert.strictEqual(error.retryAfterSeconds, undefined);
  assert.strictEqual(error.requestId, "req_429_non_numeric");
  assert.strictEqual(error.path, "/api/v1/meter/commit");
  assert.deepEqual(calls, ["/api/v1/meter/authorize", "/api/v1/meter/commit"]);
});

```

The above edit assumes:
1. `MeterPublicApiClient`, `MeterPublicApiError`, `assert`, `Response`, and `URL` are already imported in `client.test.ts`, which appears consistent with the existing tests.
2. The `meterToolCall` path used by the client is `/api/v1/meter/commit` (matching the preceding test); if the actual path differs, update the `assert.strictEqual(error.path, "...")` expectations accordingly.
3. The existing "HTTP errors expose status, response body, path, and request ID" test is elsewhere in the file; the added tests are independent and specifically target 429 responses with numeric and non-numeric `retry-after` headers to validate `retryAfterSeconds` parsing and backoff behavior.
If the client uses a different method to trigger HTTP errors (e.g., another endpoint or operation), you can adapt the body of these tests to call that method instead, but keep the 429 + `retry-after` header setup and the `retryAfterSeconds` assertions intact.
</issue_to_address>

### Comment 5
<location path="packages/adapters/test/index.test.ts" line_range="32" />
<code_context>
+  const anthropic = aiUsageFromAnthropic({
</code_context>
<issue_to_address>
**suggestion (testing):** Add a companion Anthropic test where usage is nested under `message.usage` to mirror the adapter’s fallback behavior

The adapter also handles responses where usage is under `response.message.usage`. Please add a test that sends a payload with `message: { usage: ... }` and no top-level `usage`, and asserts the same token bucket and `costMicrousd` values. This will keep both code paths covered and guard against regressions in the nested `message.usage` handling.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +878 to +887
const retryAfterRaw = res.headers.get("retry-after");
const retryAfterSeconds =
retryAfterRaw && Number.isFinite(Number(retryAfterRaw))
? Number(retryAfterRaw)
: undefined;
throw new MeterPublicApiError(
res.status,
path,
body,
res.headers.get("x-request-id") ?? undefined
res.headers.get("x-request-id") ?? undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The retry-after parsing only supports numeric seconds and silently ignores HTTP-date values.

The implementation only handles numeric Retry-After values and discards valid HTTP-date headers, so callers miss useful backoff information when servers use the date form. To better align with the spec and improve interoperability, consider parsing non-numeric values with Date.parse and converting them to seconds relative to Date.now(), while still treating malformed headers as undefined.

Suggested implementation:

    }
    if (!res.ok) {
    if (!res.ok) {
      const retryAfterRaw = res.headers.get("retry-after");
      let retryAfterSeconds: number | undefined;

      if (retryAfterRaw) {
        const numericValue = Number(retryAfterRaw);
        if (Number.isFinite(numericValue)) {
          // Numeric seconds form: Retry-After: 120
          retryAfterSeconds = numericValue;
        } else {
          // HTTP-date form: Retry-After: Wed, 21 Oct 2015 07:28:00 GMT
          const parsedDateMs = Date.parse(retryAfterRaw);
          if (!Number.isNaN(parsedDateMs)) {
            const diffMs = parsedDateMs - Date.now();
            if (diffMs > 0) {
              retryAfterSeconds = diffMs / 1000;
            }
            // If diffMs <= 0, the date is in the past; treat as no backoff (undefined)
          }
          // Malformed HTTP-date leaves retryAfterSeconds as undefined
        }
      }

      throw new MeterPublicApiError(
        res.status,
        path,
        body,
        res.headers.get("x-request-id") ?? undefined,
        retryAfterSeconds
      );
    }
  1. Ensure the MeterPublicApiError constructor accepts the new retryAfterSeconds?: number parameter and stores/exposes it appropriately.
  2. Callers that consume retryAfterSeconds should be prepared for fractional seconds (e.g., 1.5) and for undefined when the header is absent or malformed, or when the HTTP-date is already in the past.

Comment on lines +7 to +13
try {
await verifyMeterWebhookSignature({
payload,
signature: request.headers.get("x-meter-signature") ?? "",
secret: process.env.METER_WEBHOOK_SECRET!,
});
} catch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Catching all errors from verifyMeterWebhookSignature and mapping them to a 400 may hide server-side issues.

This bare catch turns all failures from verifyMeterWebhookSignature—including runtime/configuration errors—into a 400 invalid_signature, which both obscures real server problems and misleads clients. Prefer catching only the specific error that indicates a bad signature, and allow other errors to surface as 500s (with appropriate logging) so genuine infrastructure issues aren’t masked as client errors.

@@ -93,6 +94,96 @@ test("meterToolCall releases the generated reservation when protected work fails
assert.equal(calls[1]?.body.reason, "upstream failed");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add parallel tests for withUsage to cover the new commit retry and MeterCommitFailedError behavior

The new meterToolCall tests cover transient commit failures, 4xx aborts, and requestId/result preservation, but withUsage now shares this retry logic and error behavior without any targeted tests. Please add at least two withUsage tests: one where a transient 5xx commit eventually succeeds (asserting commit call count and returned result), and one where all commit attempts fail (asserting MeterCommitFailedError with the correct result/requestId and that reservations are not released). This will help prevent regressions in the withUsage path.

Suggested implementation:

test("meterToolCall retries a transient commit failure and returns the result", async () => {
  const calls: string[] = [];
  const client = new MeterPublicApiClient({
    baseUrl: "https://meter.example",
    serviceId: "service_1",
    serviceApiKey: "secret",
    fetchImpl: async (input) => {
      const path = new URL(String(input)).pathname;
      calls.push(path);
      if (path === "/api/v1/meter/commit" && calls.filter((p) => p === path).length === 1) {
        return Response.json({ error: "internal" }, { status: 503 });
      }
      return Response.json({});
    },
  });

  const result = await client.meterToolCall(
    { customerLocalId: "customer_1", tool: "search", credits: 3 },
    () => "expensive result"
  );
  assert.equal(result, "expensive result");
  assert.deepEqual(calls, [
    "/api/v1/meter/authorize",
    "/api/v1/meter/commit",
    "/api/v1/meter/commit",
  ]);
});

test("withUsage retries a transient commit failure and returns the result", async () => {
  const calls: string[] = [];
  const client = new MeterPublicApiClient({
    baseUrl: "https://meter.example",
    serviceId: "service_1",
    serviceApiKey: "secret",
    fetchImpl: async (input, init) => {
      const url = new URL(String(input));
      const path = url.pathname;
      calls.push(path);

      // Simulate an authorize call that returns a requestId
      if (path === "/api/v1/meter/authorize") {
        return Response.json({ requestId: "req_1" });
      }

      // First commit attempt fails with a transient 5xx error, second succeeds
      if (path === "/api/v1/meter/commit") {
        const commitAttempts = calls.filter((p) => p === path).length;
        if (commitAttempts === 1) {
          return Response.json({ error: "internal" }, { status: 503 });
        }
        return Response.json({ requestId: "req_1" });
      }

      return Response.json({});
    },
  });

  const result = await client.withUsage(
    { customerLocalId: "customer_1", credits: 3 },
    async () => {
      return "expensive result";
    }
  );

  assert.equal(result, "expensive result");
  assert.deepEqual(calls, [
    "/api/v1/meter/authorize",
    "/api/v1/meter/commit",
    "/api/v1/meter/commit",
  ]);
});

test("withUsage throws MeterCommitFailedError when all commit attempts fail and does not release reservations", async () => {
  const calls: string[] = [];
  const client = new MeterPublicApiClient({
    baseUrl: "https://meter.example",
    serviceId: "service_1",
    serviceApiKey: "secret",
    fetchImpl: async (input, init) => {
      const url = new URL(String(input));
      const path = url.pathname;
      calls.push(path);

      if (path === "/api/v1/meter/authorize") {
        // Return a requestId that should be surfaced on the error
        return Response.json({ requestId: "req_2" });
      }

      if (path === "/api/v1/meter/commit") {
        // All commit attempts fail with a transient 5xx error
        return Response.json({ error: "internal" }, { status: 503 });
      }

      // There should be no release calls on commit failure
      if (path === "/api/v1/meter/release") {
        return Response.json({});
      }

      return Response.json({});
    },
  });

  let caught: unknown;
  try {
    await client.withUsage(
      { customerLocalId: "customer_2", credits: 5 },
      async () => {
        return "failed result";
      }
    );
  } catch (err) {
    caught = err;
  }

  assert.ok(caught instanceof MeterCommitFailedError);
  const error = caught as MeterCommitFailedError;
  assert.equal(error.requestId, "req_2");
  assert.equal(error.result, "failed result");

  // Authorize happens once, commit is retried, and no release is performed
  assert.ok(calls.includes("/api/v1/meter/authorize"));
  const commitCalls = calls.filter((p) => p === "/api/v1/meter/commit").length;
  assert.ok(commitCalls >= 2);
  assert.ok(!calls.includes("/api/v1/meter/release"));
});

The withUsage tests above assume the following based on typical SDK patterns and the new meterToolCall behavior:

  1. MeterPublicApiClient.withUsage accepts (options, fn) where fn returns the "result" that should be preserved on MeterCommitFailedError. Adjust the call signature if your actual withUsage API differs (e.g., if it receives a usage context argument).
  2. The commit retry count is asserted as >= 2 in the failure test to avoid coupling to an exact retry limit. If your implementation has a known fixed retry count, you may tighten this assertion to an exact number and/or assert the full calls array contents.
  3. The error shape is assumed to be MeterCommitFailedError with requestId and result properties. If your error type exposes these differently (e.g., error.data.requestId or error.commit.requestId), update the assertions accordingly.
  4. If withUsage performs additional API calls (for example, a separate reservation or usage endpoint before commit), you may want to extend the calls assertions to cover those paths and ensure they behave correctly under commit failure scenarios.


test("meterToolCall retries a transient commit failure and returns the result", async () => {
const calls: string[] = [];
const client = new MeterPublicApiClient({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Extend HTTP error tests to assert retryAfterSeconds on 429 responses

Since MeterPublicApiError now exposes retryAfterSeconds from the retry-after header, please extend this test (or add a new one) to cover a 429 response with a numeric retry-after and assert the parsed retryAfterSeconds value. Also consider a case with a non-numeric retry-after to confirm retryAfterSeconds remains undefined when parsing fails, so backoff behavior is properly validated.

Suggested implementation:

  await assert.rejects(
    client.meterToolCall({ customerLocalId: "customer_1", tool: "search" }, () => "ok"),
    MeterCommitFailedError
  );
  assert.deepEqual(calls, ["/api/v1/meter/authorize", "/api/v1/meter/commit"]);
});

// HTTP error tests

test("HTTP errors expose status, response body, path, request ID, and retryAfterSeconds for numeric retry-after on 429", async () => {
  const calls: string[] = [];

  const client = new MeterPublicApiClient({
    baseUrl: "https://meter.example",
    serviceId: "service_1",
    serviceApiKey: "secret",
    fetchImpl: async (input) => {
      const url = new URL(String(input));
      calls.push(url.pathname);

      // Return a 429 with numeric retry-after and request ID headers
      return new Response(
        JSON.stringify({
          error: "rate_limit",
          message: "Too many requests",
        }),
        {
          status: 429,
          headers: {
            "retry-after": "10",
            "x-request-id": "req_429_numeric",
          },
        }
      );
    },
  });

  const error = await assert.rejects(
    client.meterToolCall({ customerLocalId: "customer_1", tool: "search" }, () => "ok"),
    (err: unknown) => err instanceof MeterPublicApiError
  );

  assert.ok(error instanceof MeterPublicApiError);
  assert.strictEqual(error.status, 429);
  assert.strictEqual(error.retryAfterSeconds, 10);
  assert.strictEqual(error.requestId, "req_429_numeric");
  assert.strictEqual(error.path, "/api/v1/meter/commit");
  assert.deepEqual(calls, ["/api/v1/meter/authorize", "/api/v1/meter/commit"]);
});

test("HTTP 429 with non-numeric retry-after leaves retryAfterSeconds undefined", async () => {
  const calls: string[] = [];

  const client = new MeterPublicApiClient({
    baseUrl: "https://meter.example",
    serviceId: "service_1",
    serviceApiKey: "secret",
    fetchImpl: async (input) => {
      const url = new URL(String(input));
      calls.push(url.pathname);

      // Return a 429 with non-numeric retry-after; parsing should fail
      return new Response(
        JSON.stringify({
          error: "rate_limit",
          message: "Too many requests",
        }),
        {
          status: 429,
          headers: {
            "retry-after": "not-a-number",
            "x-request-id": "req_429_non_numeric",
          },
        }
      );
    },
  });

  const error = await assert.rejects(
    client.meterToolCall({ customerLocalId: "customer_1", tool: "search" }, () => "ok"),
    (err: unknown) => err instanceof MeterPublicApiError
  );

  assert.ok(error instanceof MeterPublicApiError);
  assert.strictEqual(error.status, 429);
  assert.strictEqual(error.retryAfterSeconds, undefined);
  assert.strictEqual(error.requestId, "req_429_non_numeric");
  assert.strictEqual(error.path, "/api/v1/meter/commit");
  assert.deepEqual(calls, ["/api/v1/meter/authorize", "/api/v1/meter/commit"]);
});

The above edit assumes:

  1. MeterPublicApiClient, MeterPublicApiError, assert, Response, and URL are already imported in client.test.ts, which appears consistent with the existing tests.
  2. The meterToolCall path used by the client is /api/v1/meter/commit (matching the preceding test); if the actual path differs, update the assert.strictEqual(error.path, "...") expectations accordingly.
  3. The existing "HTTP errors expose status, response body, path, and request ID" test is elsewhere in the file; the added tests are independent and specifically target 429 responses with numeric and non-numeric retry-after headers to validate retryAfterSeconds parsing and backoff behavior.
    If the client uses a different method to trigger HTTP errors (e.g., another endpoint or operation), you can adapt the body of these tests to call that method instead, but keep the 429 + retry-after header setup and the retryAfterSeconds assertions intact.

assert.equal(anthropic.inputTokens, 80);
assert.equal(anthropic.cachedInputTokens, 20);
assert.equal(anthropic.totalTokens, 105);
assert.equal(anthropic.costMicrousd, 321);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a companion Anthropic test where usage is nested under message.usage to mirror the adapter’s fallback behavior

The adapter also handles responses where usage is under response.message.usage. Please add a test that sends a payload with message: { usage: ... } and no top-level usage, and asserts the same token bucket and costMicrousd values. This will keep both code paths covered and guard against regressions in the nested message.usage handling.

@masterleopold
masterleopold merged commit 438038d into main Jul 11, 2026
5 checks passed
@masterleopold
masterleopold deleted the fix/honest-sdk-types branch July 11, 2026 10:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant