Skip to content

fix(sdk): authorize() no longer stacks request interceptors#390

Merged
anttiviljami merged 1 commit into
mainfrom
fix/authorize-no-interceptor-stack
May 19, 2026
Merged

fix(sdk): authorize() no longer stacks request interceptors#390
anttiviljami merged 1 commit into
mainfrom
fix/authorize-no-interceptor-stack

Conversation

@anttiviljami
Copy link
Copy Markdown
Member

Summary

authorize(client, fn) registered a new request interceptor on every call without ever ejecting the prior one. Because axios runs request interceptors in reverse registration order, the oldest token always won at request time — exactly the opposite of what callers expect when they re-authorize a singleton client.

Real-world bug this fixes

We just hit this in configuration-hub-api's cross-org sync feature:

  • Phase 0 (read source org): authorize(entityClient, () => sourceToken)
  • Phase A (write target org): authorize(entityClient, () => targetToken)
  • HTTP POST goes out → axios runs both interceptors in reverse → source token wins
  • Every "create" landed on the source org instead of the target org
  • API returned 201 with org_id=sourceOrg; the orchestrator stored phantom target_id values that 404 on lookup
  • Lineage table got polluted

Confirmed via direct curl: the target token writes correctly to the target org when used standalone. The bug is purely in the interceptor stacking.

What changed

  • Track the previously-installed auth interceptor id on the client via a Symbol.for('@epilot/sdk:authInterceptorId') marker
  • Eject the prior interceptor before installing a new one
  • When switching into function-form, also clear any stale defaults.headers.common.authorization left over from a previous string-form call so the two forms cannot race
  • No public API change; signature is identical

Tests

Added packages/epilot-sdk-v2/__tests__/authorize.test.ts (8 new tests, using real axios.create() and the public interceptors.request.handlers array — not mocks):

  • Last call wins when re-authorizing with a function token (regression for the cross-org bug)
  • Re-authorizing N times keeps exactly one active auth interceptor
  • 50 successive authorize() calls do not grow the handler list
  • function-form → string-form clears the prior interceptor
  • string-form → function-form clears the stale default header
  • String form remains a simple defaults overwrite (no interceptor)
  • Unrelated request interceptors (e.g. tracing) are preserved across re-authorization
  • Still returns the client to support chaining

Test plan

  • pnpm test passes (318 tests, +8 new)
  • pnpm build succeeds
  • pnpm lint clean
  • Verify the configuration-hub-api cross-org sync writes land on the target org after picking up this SDK release

🤖 Generated with Claude Opus 4.7 (1M context)

When authorize(client, fn) was called more than once on the same axios
client, each call registered a new request interceptor without ejecting
prior ones. Because axios runs request interceptors in REVERSE
registration order, the OLDEST token won at request time -- exactly the
opposite of what callers expect when re-authorizing a singleton client.

Real-world impact: a cross-org sync flow in configuration-hub-api that
re-authorized the entity client (source token then target token) wrote
every "create" to the SOURCE org. The API returned 201s with
org_id=sourceOrg, and the orchestrator silently stored phantom target
ids that 404 on lookup.

Fix: track the previously-installed auth interceptor id on the client
via a Symbol.for marker and eject it before installing the new one.
Also clear the static defaults.headers.common.authorization when
switching into function-form so the two forms can't race.

Regression coverage added in __tests__/authorize.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Co-authored-by: Claude <noreply@anthropic.com>
@anttiviljami anttiviljami merged commit be511ad into main May 19, 2026
6 of 7 checks passed
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