Skip to content
Merged
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
1 change: 0 additions & 1 deletion scripts/lint/module-boundaries-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@
"cycle-sensitive:src/platform/adapters/token/veryfront/types.ts -> #veryfront/errors",
"cycle-sensitive:src/platform/adapters/veryfront-api-client/client.ts -> #veryfront/utils",
"cycle-sensitive:src/platform/adapters/veryfront-api-client/operations.ts -> #veryfront/utils",
"cycle-sensitive:src/platform/adapters/veryfront-api-client/retry-handler.ts -> #veryfront/utils",
"cycle-sensitive:src/platform/compat/http/websocket.ts -> #veryfront/errors",
"cycle-sensitive:src/platform/compat/kv/factory.ts -> #veryfront/utils",
"cycle-sensitive:src/types/entities/getEntityInfo.ts -> #veryfront/utils",
Expand Down
128 changes: 43 additions & 85 deletions src/platform/adapters/token/veryfront/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { logger as baseLogger } from "#veryfront/utils";
import { injectContext } from "#veryfront/observability/tracing/otlp-setup.ts";
import { type VeryfrontTokenConfig } from "./types.ts";
import { TOKEN_STORAGE_ERROR } from "#veryfront/errors/error-registry.ts";
import { VeryfrontError } from "#veryfront/errors/types.ts";
import { retryWithBackoff } from "#veryfront/errors/error-handlers.ts";
import {
createVeryfrontApiTransport,
type VeryfrontApiTransport,
} from "../../veryfront-api-transport.ts";

const logger = baseLogger.component("token-storage-api-client");

Expand All @@ -26,19 +28,50 @@ async function cancelResponseBody(response: Response, operation: string): Promis

export class TokenStorageApiClient {
private config: VeryfrontTokenConfig;
private transport: VeryfrontApiTransport<Response>;

constructor(config: VeryfrontTokenConfig) {
this.config = config;

const { maxRetries, initialDelay, maxDelay } = config.retry;
const timeoutMs = config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;

this.transport = createVeryfrontApiTransport<Response>({
baseUrl: config.apiBaseUrl,
getToken: () => config.apiToken,
retry: { maxRetries, initialDelay, maxDelay },
timeoutMs,
defaultHeaders: { "Accept": "application/json" },

onResponse: async (response) => {
// 4xx non-429: pass the response through so callers handle it.
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
return response;
}
// 5xx / 429: throw to trigger retry logic. Cancel the body first so
// retries do not hold connections/buffers open.
if (!response.ok && (response.status >= 500 || response.status === 429)) {
await cancelResponseBody(response, "retry");
throw TOKEN_STORAGE_ERROR.create({
detail: `Server error: ${response.status}`,
status: response.status,
});
}
return response;
},

wrapFinalError: (lastError) =>
TOKEN_STORAGE_ERROR.create({
detail: `Request failed after ${maxRetries} retries: ${lastError.message}`,
}),
});
}

async get(key: string): Promise<string | null> {
const url = this.buildUrl(key);

try {
const response = await this.fetchWithRetry(url, {
method: "GET",
headers: this.buildHeaders(),
});
const response = await this.transport.request(url);

if (response.status === 404) {
return null;
Expand All @@ -63,12 +96,9 @@ export class TokenStorageApiClient {
const url = this.buildUrl(key);

try {
const response = await this.fetchWithRetry(url, {
const response = await this.transport.request(url, {
method: "PUT",
headers: {
...this.buildHeaders(),
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});

Expand All @@ -88,10 +118,7 @@ export class TokenStorageApiClient {
const url = this.buildUrl(key);

try {
const response = await this.fetchWithRetry(url, {
method: "DELETE",
headers: this.buildHeaders(),
});
const response = await this.transport.request(url, { method: "DELETE" });

if (response.ok || response.status === 404) {
return;
Expand All @@ -118,10 +145,7 @@ export class TokenStorageApiClient {
}

try {
const response = await this.fetchWithRetry(url.toString(), {
method: "GET",
headers: this.buildHeaders(),
});
const response = await this.transport.request(url.toString());

if (!response.ok) {
await cancelResponseBody(response, "list");
Expand Down Expand Up @@ -167,13 +191,6 @@ export class TokenStorageApiClient {
}/tokens/${encodeURIComponent(key)}`;
}

private buildHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.config.apiToken}`,
Accept: "application/json",
};
}

private wrapError(
error: unknown,
action: "Get" | "Set" | "Delete",
Expand All @@ -190,63 +207,4 @@ export class TokenStorageApiClient {

return TOKEN_STORAGE_ERROR.create({ detail: `${prefixMessage}: ${message}` });
}

private logTimedOut(url: string, timeoutMs: number, attempt: number): void {
logger.warn("Request timed out", {
url: url.replace(/token=[^&]+/, "token=***"),
timeoutMs,
attempt: attempt + 1,
});
}

private async fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
const { maxRetries, initialDelay, maxDelay } = this.config.retry;
const timeoutMs = this.config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;

return retryWithBackoff(
(signal) => {
const headers = new Headers(init.headers);
injectContext(headers);

return fetch(url, { ...init, headers, signal }).then((response) => {
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
return response;
}

if (!response.ok && (response.status >= 500 || response.status === 429)) {
throw TOKEN_STORAGE_ERROR.create({
detail: `Server error: ${response.status}`,
status: response.status,
});
}

return response;
});
},
{
maxAttempts: maxRetries + 1,
initialDelay,
maxDelay,
timeoutMs,
onRetry: ({ error, attempt, delay, isTimeout }) => {
if (isTimeout) this.logTimedOut(url, timeoutMs, attempt);

logger.warn("Request failed, retrying...", {
attempt: attempt + 1,
maxRetries,
delay,
error: error.message,
timeout: isTimeout,
});
},
wrapFinalError: (lastError, lastAttempt) => {
if (lastError.name === "AbortError") this.logTimedOut(url, timeoutMs, lastAttempt);

return TOKEN_STORAGE_ERROR.create({
detail: `Request failed after ${maxRetries} retries: ${lastError.message}`,
});
},
},
);
}
}
25 changes: 15 additions & 10 deletions src/platform/adapters/veryfront-api-client/operations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { logger as baseLogger } from "#veryfront/utils";
import { type RequestOptions, requestWithRetry, type RetryConfig } from "./retry-handler.ts";
import {
createCanonicalVeryfrontApiTransport,
type TransportRequestInit,
type TransportRetryConfig,
type VeryfrontApiTransport,
} from "../veryfront-api-transport.ts";
import { API_CLIENT_ERROR, VeryfrontError } from "./types.ts";
import {
getBranchFileDetailSchema,
Expand Down Expand Up @@ -186,16 +191,22 @@ async function listAllFiles(

export class VeryfrontAPIOperations {
private tokenProvider: TokenProvider;
private transport: VeryfrontApiTransport<unknown>;

constructor(
private apiBaseUrl: string,
tokenOrProvider: string | TokenProvider,
private retryConfig: RetryConfig,
retryConfig: TransportRetryConfig,
private projectId?: string,
) {
this.tokenProvider = typeof tokenOrProvider === "string"
? () => tokenOrProvider
: tokenOrProvider;
this.transport = createCanonicalVeryfrontApiTransport(
apiBaseUrl,
() => this.tokenProvider(),
retryConfig,
Comment thread
kojiwakayama marked this conversation as resolved.
);
}

setTokenProvider(provider: TokenProvider): void {
Expand Down Expand Up @@ -699,16 +710,10 @@ export class VeryfrontAPIOperations {
return getReleaseAssetManifestResponseSchema().parse(raw);
}

private request(endpoint: string, options: RequestOptions = {}): Promise<unknown> {
private request(endpoint: string, options: TransportRequestInit = {}): Promise<unknown> {
return withSpan(
SpanNames.API_REQUEST,
() =>
requestWithRetry(
`${this.apiBaseUrl}${endpoint}`,
this.tokenProvider(),
this.retryConfig,
options,
),
() => this.transport.request(`${this.apiBaseUrl}${endpoint}`, options),
{ "api.endpoint": endpoint, "api.base_url": this.apiBaseUrl },
);
}
Expand Down
Loading