239 lines
8.8 KiB
TypeScript
239 lines
8.8 KiB
TypeScript
import { AppError } from "@/server/lib/errors";
|
|
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
|
|
import type { ErrorCode } from "@/shared/error-codes";
|
|
// Type-only: erased at compile, so no runtime cycle with envelope.ts (which
|
|
// imports DataforseoErrorClassifier from here the same way).
|
|
import type {
|
|
DataforseoResponseLike,
|
|
DataforseoTaskLike,
|
|
} from "@/server/lib/dataforseo/envelope";
|
|
|
|
const API_BASE = "https://api.dataforseo.com";
|
|
const MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH = 1600;
|
|
// Safety ceiling on any live call (Lighthouse is the slowest, ~tens of seconds).
|
|
const DATAFORSEO_REQUEST_TIMEOUT_MS = 60_000;
|
|
// Retry idempotent reads on transient 5xx. Total attempts = retries + 1; the
|
|
// shared request-timeout signal still caps overall wall time.
|
|
const DATAFORSEO_MAX_RETRIES = 2;
|
|
const DATAFORSEO_RETRY_BACKOFF_MS = 250;
|
|
|
|
/**
|
|
* Translates a DataForSEO HTTP/task failure into a product-specific AppError
|
|
* (e.g. "billing issue"). Returns null when the failure isn't one this
|
|
* classifier recognises, so the caller can fall back to a generic error. See
|
|
* {@link createDataforseoBillingClassifier}.
|
|
*/
|
|
export type DataforseoErrorClassifier = (
|
|
status: number | undefined,
|
|
details: string,
|
|
path: string,
|
|
) => AppError | null;
|
|
|
|
function formatDataforseoErrorPayload(value: unknown): string {
|
|
const text =
|
|
typeof value === "string"
|
|
? value
|
|
: (() => {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
})();
|
|
|
|
return text.length > MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH
|
|
? `${text.slice(0, MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH)}... [truncated]`
|
|
: text;
|
|
}
|
|
|
|
function formatDataforseoRequestPath(url: RequestInfo): string {
|
|
const rawUrl = typeof url === "string" ? url : url.url;
|
|
try {
|
|
return new URL(rawUrl).pathname;
|
|
} catch {
|
|
return rawUrl;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The single authenticated `fetch` used by every DataForSEO call. Throws on
|
|
* non-2xx; task-level failures (which return HTTP 200) are handled downstream
|
|
* by {@link assertOk}. An optional classifier maps recognised HTTP failures to
|
|
* product errors.
|
|
*/
|
|
function createAuthenticatedFetch(
|
|
classify?: DataforseoErrorClassifier,
|
|
maxServerErrorRetries = DATAFORSEO_MAX_RETRIES,
|
|
) {
|
|
return async (url: RequestInfo, init?: RequestInit): Promise<Response> => {
|
|
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
|
|
const headers = new Headers(init?.headers);
|
|
headers.set("Authorization", `Basic ${apiKey}`);
|
|
// Resolve the signal once so retries share the overall request timeout
|
|
// rather than restarting a fresh 60s budget on each attempt.
|
|
const signal =
|
|
init?.signal ?? AbortSignal.timeout(DATAFORSEO_REQUEST_TIMEOUT_MS);
|
|
|
|
for (let attempt = 0; ; attempt++) {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, { ...init, headers, signal });
|
|
} catch (error) {
|
|
// Both abort flavours mean "we ran out of time", and neither carries a
|
|
// useful name of its own: the shared budget above rejects with
|
|
// TimeoutError, while Lighthouse passes its own AbortController
|
|
// deadline via init.signal and rejects with AbortError. Classify them
|
|
// so callers get a provider-degradation error instead of an anonymous
|
|
// internal one. Deliberately not retried: a call that ran past the
|
|
// deadline may already be billed by DataForSEO, and because this is not
|
|
// a DataforseoChargedTaskError the customer is metered nothing for it,
|
|
// so replaying would be spend we eat twice.
|
|
if (
|
|
error instanceof Error &&
|
|
(error.name === "TimeoutError" || error.name === "AbortError")
|
|
) {
|
|
const path = formatDataforseoRequestPath(url);
|
|
const timeoutError = new AppError(
|
|
"UPSTREAM_UNAVAILABLE",
|
|
`DataForSEO request timed out on ${path}`,
|
|
{ provider: "dataforseo", providerPath: path },
|
|
);
|
|
timeoutError.name = "DataForSEOTimeoutError";
|
|
throw timeoutError;
|
|
}
|
|
throw error;
|
|
}
|
|
if (response.ok) return response;
|
|
|
|
// Transient upstream 5xx on an idempotent read -> back off and retry.
|
|
if (response.status >= 500 && attempt < maxServerErrorRetries) {
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, DATAFORSEO_RETRY_BACKOFF_MS * (attempt + 1)),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const rawText = await response.text();
|
|
const path = formatDataforseoRequestPath(url);
|
|
const classified = classify?.(response.status, rawText, path);
|
|
if (classified) throw classified;
|
|
|
|
const code: ErrorCode =
|
|
response.status >= 500
|
|
? "UPSTREAM_UNAVAILABLE"
|
|
: response.status === 429
|
|
? "RATE_LIMITED"
|
|
: response.status === 401
|
|
? "DATAFORSEO_AUTH_FAILED"
|
|
: "INTERNAL_ERROR";
|
|
const error = new AppError(
|
|
code,
|
|
`DataForSEO HTTP ${response.status} on ${path}`,
|
|
{
|
|
provider: "dataforseo",
|
|
providerStatus: String(response.status),
|
|
providerPath: path,
|
|
responseBody: formatDataforseoErrorPayload(rawText),
|
|
},
|
|
);
|
|
error.name = "DataForSEOHttpError";
|
|
// UPSTREAM_UNAVAILABLE is non-reportable, and the error handlers only log
|
|
// what they capture, so warn here to keep the provider's failure rate
|
|
// visible in Workers Observability. Warn, not error: there is nothing in
|
|
// the app to fix.
|
|
if (code === "UPSTREAM_UNAVAILABLE")
|
|
console.warn("dataforseo.upstream-http-failed", {
|
|
path,
|
|
status: response.status,
|
|
});
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
type DataforseoRequestOptions = {
|
|
/** Maps a recognised access / billing HTTP failure to a product error. */
|
|
classify?: DataforseoErrorClassifier;
|
|
/**
|
|
* Set 0 for billed, non-idempotent calls (business task_post, Lighthouse):
|
|
* a 5xx does not prove the provider skipped the charge, so those must never
|
|
* be replayed. Defaults to retrying idempotent reads on transient 5xx.
|
|
*/
|
|
maxServerErrorRetries?: number;
|
|
};
|
|
|
|
async function requestDataforseo<TTask extends DataforseoTaskLike>(
|
|
method: "GET" | "POST",
|
|
path: string,
|
|
body: unknown,
|
|
options: DataforseoRequestOptions,
|
|
): Promise<DataforseoResponseLike<TTask> | null> {
|
|
const doFetch = createAuthenticatedFetch(
|
|
options.classify,
|
|
options.maxServerErrorRetries,
|
|
);
|
|
const response = await doFetch(`${API_BASE}${path}`, {
|
|
method,
|
|
headers: {
|
|
Accept: "application/json",
|
|
...(method === "POST" ? { "Content-Type": "application/json" } : {}),
|
|
},
|
|
body: method === "POST" ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await response.text();
|
|
if (text === "") return null;
|
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the task type is the caller's claim about the payload; billing metadata and item fields are validated downstream (envelope.ts + section Zod schemas)
|
|
return JSON.parse(text) as DataforseoResponseLike<TTask>;
|
|
}
|
|
|
|
/**
|
|
* POST `tasks` (the standard array-of-task-payloads body) to a DataForSEO
|
|
* endpoint and return the parsed response envelope. The task type parameter is
|
|
* the caller's claim about the payload shape — fields we act on are validated
|
|
* downstream (billing metadata in envelope.ts, items via the section fetchers'
|
|
* Zod schemas). Auth is read per-call from the Worker env.
|
|
*/
|
|
export function dataforseoPost<
|
|
TTask extends DataforseoTaskLike = DataforseoTaskLike,
|
|
>(
|
|
path: string,
|
|
tasks: unknown[],
|
|
options: DataforseoRequestOptions = {},
|
|
): Promise<DataforseoResponseLike<TTask> | null> {
|
|
return requestDataforseo("POST", path, tasks, options);
|
|
}
|
|
|
|
/**
|
|
* POST like {@link dataforseoPost} but return the UN-CONSUMED Response
|
|
* (headers only) — the same auth, retry policy, and HTTP-error ladder apply.
|
|
* For the one endpoint whose body is too big to read eagerly: Lighthouse
|
|
* gates its multi-MB body reads behind a parse lock in the audit worker, and
|
|
* passes its own `signal` so the timeout can be cleared once headers arrive.
|
|
*/
|
|
export function dataforseoPostResponse(
|
|
path: string,
|
|
tasks: unknown[],
|
|
options: DataforseoRequestOptions & { signal?: AbortSignal } = {},
|
|
): Promise<Response> {
|
|
const doFetch = createAuthenticatedFetch(
|
|
options.classify,
|
|
options.maxServerErrorRetries,
|
|
);
|
|
return doFetch(`${API_BASE}${path}`, {
|
|
method: "POST",
|
|
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
body: JSON.stringify(tasks),
|
|
signal: options.signal,
|
|
});
|
|
}
|
|
|
|
/** GET a DataForSEO endpoint (task_get collection, appendix/locations data). */
|
|
export function dataforseoGet<
|
|
TTask extends DataforseoTaskLike = DataforseoTaskLike,
|
|
>(
|
|
path: string,
|
|
options: DataforseoRequestOptions = {},
|
|
): Promise<DataforseoResponseLike<TTask> | null> {
|
|
return requestDataforseo("GET", path, undefined, options);
|
|
}
|