Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/summary-retry-backoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"braintrust": patch
---

Add exponential backoff between existing `get_json` retry attempts.
67 changes: 67 additions & 0 deletions js/src/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1718,6 +1718,73 @@ test("simulateLoginForTests and simulateLogoutForTests", async () => {
}
});

describe("HTTPConnection get_json retries", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
_exportsForTestingOnly.simulateLogoutForTests();
});

test("backs off before retrying", async () => {
vi.useFakeTimers();

const state = await _exportsForTestingOnly.simulateLoginForTests();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response("timeout", {
status: 504,
statusText: "Gateway Timeout",
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
state.setFetch(fetchMock as unknown as typeof globalThis.fetch);

const resultPromise = state.apiConn().get_json("/retry-me", undefined, 1);

await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(999);
expect(fetchMock).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
});

test("throws after retry exhaustion", async () => {
vi.useFakeTimers();

const state = await _exportsForTestingOnly.simulateLoginForTests();
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response("timeout", {
status: 504,
statusText: "Gateway Timeout",
}),
),
);
state.setFetch(fetchMock as unknown as typeof globalThis.fetch);

const resultPromise = state.apiConn().get_json("/retry-me", undefined, 2);
const expectedFailure = expect(resultPromise).rejects.toThrow(
"504: Gateway Timeout",
);

await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(2000);

await expectedFailure;
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});

test("span.export handles unauthenticated state", async () => {
// Create a span without logging in
const logger = initLogger({});
Expand Down
6 changes: 6 additions & 0 deletions js/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,11 @@ class HTTPConnection {
(e as any).status
} ${(e as any).text}`,
);
const sleepTimeS = HTTP_RETRY_BASE_SLEEP_TIME_S * 2 ** i;
debugLogger.info(`Sleeping for ${sleepTimeS}s`);
await new Promise((resolve) =>
setTimeout(resolve, sleepTimeS * 1000),
);
continue;
}
throw e;
Expand Down Expand Up @@ -2765,6 +2770,7 @@ export class TestBackgroundLogger implements BackgroundLogger {
}

const BACKGROUND_LOGGER_BASE_SLEEP_TIME_S = 1.0;
const HTTP_RETRY_BASE_SLEEP_TIME_S = 1.0;

// We should only have one instance of this object per state object in
// 'BraintrustState._bgLogger'. Be careful about spawning multiple
Expand Down
Loading