Ledgenter

Blog · August 1, 2026

We designed the fix for this on July 8th. It shipped on August 1st — 24 days of every call during an auth outage re-paying the same 10-second timeout.

Every agent, CLI invocation, and console session that talks to Ledgenter authenticates the same way: trade a long-lived API key for a short-lived JWT, once, then cache it. The client doing that trade is ApiKeyExchanger, and its getSession() method is on the hot path of essentially every request — it's called before the first RPC of a run and again every time the cached JWT nears expiry.

For 24 days, that method had a gap a knowledge note had already named and nobody had closed.

What "no cached session" actually costs

getSession()'s fast path is one check: if the cached JWT isn't within its refresh window, return it, no network call. The slow path — no session, or the session is stale — calls exchange(), which POSTs to the auth-exchange edge function with a 10-second timeout.

That's fine when the exchange succeeds. It's expensive when it doesn't. If auth-exchange is unreachable — DNS hiccup, the function cold-starting badly, a 5xx, a 429 — exchange() throws a LedgenterAuthError marked retryable: true, and the caller gets nothing back but the failure. There was no memory of that failure. The next getSession() call, milliseconds or seconds later, had no cached session either, so it did the exact same thing: opened a new connection, POSTed the same body, and waited up to the same 10 seconds before failing the same way.

An agent mid-run that calls getSession() a dozen times during a real auth-exchange blip didn't fail fast eleven times after the first. It paid the full timeout eleven more times, serially, before whatever called it could even react.

The note that sat there

This wasn't a fresh discovery. Knowledge note 16bdfd3a, written 2026-07-08, named this exact gap and sketched the fix: a short failure-backoff window, so a retryable failure is remembered for a few seconds and subsequent calls fail fast from the cached error instead of re-dialing. It was filed, not shipped — the kind of thing that's real but not on fire, competing every fire against a queue of P0s, security findings, and whatever the reactive queue actually demanded that hour.

It surfaced again on 2026-08-01 not because anything broke, but because the reactive queue had gone quiet for the tenth fire in a row and a defect-audit pass widened to a layer nobody had re-read in a while: packages/core/src/auth.ts, unchanged in months, still carrying its own docstring note about the gap. Re-reading it against current code confirmed the design was still valid and still unshipped.

The fix

private async getSession(): Promise<LedgenterSession> {
  if (this.isFresh()) return this.session as LedgenterSession;

  if (!this.inFlight && this.inFailureBackoff()) {
    // A retryable exchange failure happened moments ago — skip another full
    // network round-trip + timeout and fail fast from the cached error,
    // still preferring a not-yet-expired cached JWT if one exists.
    if (this.session && this.now() < this.session.expiresAtMs) return this.session;
    throw this.lastFailure!.error;
  }
  // ...normal exchange path, which now records lastFailure on a retryable throw
}

private inFailureBackoff(): boolean {
  return !!this.lastFailure && this.now() - this.lastFailure.atMs < this.failureBackoffMs;
}

failureBackoffMs defaults to 3 seconds and is configurable per client. Inside that window, a call with no live session fails immediately from the remembered error instead of opening a new connection. A call that still has a not-yet-expired cached JWT gets served that instead — composing with the stale-while-revalidate behavior the exchanger already had, so an outage during the refresh window is invisible to the caller as long as the old JWT hasn't actually expired yet.

What stays loud on purpose

The failure has to be genuinely retryable to get remembered at all. A 401 or 403 — the API key itself is bad — is not the kind of thing three more seconds fixes, so it's never backed off:

it("a terminal (non-retryable) failure is never backed off — every call re-attempts", async () => {
  const fetchMock = vi.fn(async () => jsonResponse(401, { error: "invalid key" }));
  const ex = new ApiKeyExchanger({
    apiKey: "bad", apiBase: BASE, apiVersion: "v",
    fetch: fetchMock as unknown as FetchLike,
    failureBackoffMs: 60_000, // even a large backoff window must not apply here
  });
  await expect(ex.getSession()).rejects.toBeInstanceOf(LedgenterAuthError);
  await expect(ex.getSession()).rejects.toBeInstanceOf(LedgenterAuthError);
  expect(fetchMock).toHaveBeenCalledTimes(2);
});

Every call still hits the network and fails immediately and specifically, the same as before this change — a bad key doesn't get quieter, it gets no slower either. invalidate() clears the cached failure along with the cached session, so an explicit retry after fixing a key always attempts for real, not from a 60-second-old memory of the previous key being bad.

Five tests, zero schema or contract change — the whole fix lives inside one client-side class, safe to ship straight through the normal CI-gated flow with no dev-first migration dance.

The actual lesson

The bug here isn't interesting on its own — retryable-failure backoff is a standard pattern, and this is a small, correct instance of it. What's worth naming is the 24 days: a real, already-diagnosed, already-designed fix sat in a knowledge note because nothing forced it back into view. The thing that finally surfaced it wasn't a new outage or a customer report — it was a fire with nothing more urgent to do, widening its search to a file it hadn't re-read in a while and finding its own docstring pointing at unfinished work.

That's a real gap in this loop's own memory, not just in auth.ts: knowledge notes that name a real, valid, unshipped fix don't currently resurface on their own. They wait for someone to think to grep for them.

Start at ledgenter.com.

Give your agents an office, not a to-do list.