Triage unresolved PostHog errors: DataForSEO validation, DO retry, Autumn 429, exception noise (#333)
This commit is contained in:
parent
93a373129e
commit
c9bcc444b4
@ -30,6 +30,7 @@ function isIgnorableException(
|
|||||||
const value = typeof entry?.value === "string" ? entry.value : "";
|
const value = typeof entry?.value === "string" ? entry.value : "";
|
||||||
if (value.includes("Object Not Found Matching Id")) return true;
|
if (value.includes("Object Not Found Matching Id")) return true;
|
||||||
if (value === "Script error.") return true;
|
if (value === "Script error.") return true;
|
||||||
|
if (value.includes("signal is aborted without reason")) return true;
|
||||||
const frames = entry?.stacktrace?.frames;
|
const frames = entry?.stacktrace?.frames;
|
||||||
return (
|
return (
|
||||||
value === "undefined" &&
|
value === "undefined" &&
|
||||||
|
|||||||
@ -71,6 +71,28 @@ export class OnboardingChatAgent extends AIChatAgent {
|
|||||||
// Cap stored history; the onboarding chat is short and pre-paywall.
|
// Cap stored history; the onboarding chat is short and pre-paywall.
|
||||||
maxPersistedMessages = 60;
|
maxPersistedMessages = 60;
|
||||||
|
|
||||||
|
// The base class persists each message as its own bounded SQLite row, so DO
|
||||||
|
// storage occasionally returns a transient internal error (code 10001) that
|
||||||
|
// clears on retry. Retry the message-write path a couple of times before
|
||||||
|
// surfacing the failure, rethrowing on non-transient errors or the last try.
|
||||||
|
async persistMessages(
|
||||||
|
...args: Parameters<AIChatAgent["persistMessages"]>
|
||||||
|
): Promise<void> {
|
||||||
|
const maxAttempts = 3;
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await super.persistMessages(...args);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const transient =
|
||||||
|
message.includes("internal error") || message.includes("10001");
|
||||||
|
if (!transient || attempt >= maxAttempts) throw error;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onChatMessage(
|
async onChatMessage(
|
||||||
onFinish: StreamTextOnFinishCallback<ToolSet>,
|
onFinish: StreamTextOnFinishCallback<ToolSet>,
|
||||||
options?: OnChatMessageOptions,
|
options?: OnChatMessageOptions,
|
||||||
|
|||||||
@ -274,6 +274,51 @@ describe("meterDataforseoCall with split balances", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips the charge for an unbilled invalid-field failure and rethrows VALIDATION_ERROR", async () => {
|
||||||
|
setupHostedMode();
|
||||||
|
mockBalances(5000, 3000);
|
||||||
|
vi.mocked(fetchBacklinksSummary).mockRejectedValue(
|
||||||
|
new DataforseoChargedTaskError(
|
||||||
|
"Invalid Field: 'target'.",
|
||||||
|
{ costUsd: 0, path: ["v3", "backlinks", "summary", "live"] },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = createDataforseoClient(billingCustomer);
|
||||||
|
await expect(
|
||||||
|
client.backlinks.summary(backlinksInput),
|
||||||
|
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
|
||||||
|
|
||||||
|
expect(trackMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still meters an invalid-field failure that DataForSEO actually billed", async () => {
|
||||||
|
setupHostedMode();
|
||||||
|
mockBalances(5000, 3000);
|
||||||
|
vi.mocked(fetchBacklinksSummary).mockRejectedValue(
|
||||||
|
new DataforseoChargedTaskError(
|
||||||
|
"Invalid Field: 'target'.",
|
||||||
|
{ costUsd: RAW_COST, path: ["v3", "backlinks", "summary", "live"] },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = createDataforseoClient(billingCustomer);
|
||||||
|
await expect(client.backlinks.summary(backlinksInput)).rejects.toThrow(
|
||||||
|
"Invalid Field: 'target'.",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(trackMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(trackMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
customerId: "org_123",
|
||||||
|
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||||
|
value: EXPECTED_CREDITS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes balanceFeatureId in track properties", async () => {
|
it("includes balanceFeatureId in track properties", async () => {
|
||||||
setupHostedMode();
|
setupHostedMode();
|
||||||
mockBalances(30, 5000);
|
mockBalances(30, 5000);
|
||||||
|
|||||||
@ -53,6 +53,7 @@ import {
|
|||||||
type DataforseoApiResponse,
|
type DataforseoApiResponse,
|
||||||
} from "@/server/lib/dataforseo/envelope";
|
} from "@/server/lib/dataforseo/envelope";
|
||||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
|
||||||
export { mapDataforseoPathToCreditFeature };
|
export { mapDataforseoPathToCreditFeature };
|
||||||
|
|
||||||
@ -161,6 +162,14 @@ async function meterDataforseoCall<T>(
|
|||||||
result = await execute();
|
result = await execute();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DataforseoChargedTaskError) {
|
if (error instanceof DataforseoChargedTaskError) {
|
||||||
|
// A malformed request (DataForSEO "Invalid Field: ...") that DataForSEO
|
||||||
|
// did not bill returns no value to the customer, so don't charge — surface
|
||||||
|
// it as a non-reportable VALIDATION_ERROR. If DataForSEO still billed us
|
||||||
|
// (costUsd > 0), fall through to the normal charge + capture path so the
|
||||||
|
// spend stays metered and visible instead of silently eaten.
|
||||||
|
if (error.isInvalidField && error.billing.costUsd <= 0) {
|
||||||
|
throw new AppError("VALIDATION_ERROR", error.message);
|
||||||
|
}
|
||||||
await trackDataforseoCost({
|
await trackDataforseoCost({
|
||||||
customer,
|
customer,
|
||||||
customerId: billingCustomer.id,
|
customerId: billingCustomer.id,
|
||||||
|
|||||||
@ -29,6 +29,13 @@ export class DataforseoChargedTaskError extends AppError {
|
|||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public readonly billing: DataforseoApiCallCost,
|
public readonly billing: DataforseoApiCallCost,
|
||||||
|
/**
|
||||||
|
* True when the task failed because OUR request was malformed (DataForSEO
|
||||||
|
* "Invalid Field: ..."). The customer got no value, so — when the task
|
||||||
|
* wasn't billed — meterDataforseoCall skips the charge and rethrows this as
|
||||||
|
* a non-reportable VALIDATION_ERROR.
|
||||||
|
*/
|
||||||
|
public readonly isInvalidField = false,
|
||||||
) {
|
) {
|
||||||
super("INTERNAL_ERROR", message);
|
super("INTERNAL_ERROR", message);
|
||||||
this.name = "DataforseoChargedTaskError";
|
this.name = "DataforseoChargedTaskError";
|
||||||
@ -82,6 +89,8 @@ export function buildTaskBilling(
|
|||||||
return billing;
|
return billing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INVALID_FIELD_MESSAGE_RE = /Invalid Field:\s*'([^']+)'/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DataForSEO echoes the posted request params back on `task.data`. Its
|
* DataForSEO echoes the posted request params back on `task.data`. Its
|
||||||
* validation rejections are opaque ("Invalid Field: 'target'.") and name the
|
* validation rejections are opaque ("Invalid Field: 'target'.") and name the
|
||||||
@ -93,7 +102,7 @@ function describeInvalidField(
|
|||||||
message: string,
|
message: string,
|
||||||
task: DataforseoTaskLike,
|
task: DataforseoTaskLike,
|
||||||
): string {
|
): string {
|
||||||
const match = message.match(/Invalid Field:\s*'([^']+)'/i);
|
const match = message.match(INVALID_FIELD_MESSAGE_RE);
|
||||||
if (!match) return message;
|
if (!match) return message;
|
||||||
const field = match[1];
|
const field = match[1];
|
||||||
if (!isRecord(task.data)) return message;
|
if (!isRecord(task.data)) return message;
|
||||||
@ -165,7 +174,12 @@ export function assertOk<T extends DataforseoTaskLike>(
|
|||||||
|
|
||||||
const detailedMessage = describeInvalidField(message, task);
|
const detailedMessage = describeInvalidField(message, task);
|
||||||
const billing = tryBuildTaskBilling(task);
|
const billing = tryBuildTaskBilling(task);
|
||||||
if (billing) throw new DataforseoChargedTaskError(detailedMessage, billing);
|
if (billing)
|
||||||
|
throw new DataforseoChargedTaskError(
|
||||||
|
detailedMessage,
|
||||||
|
billing,
|
||||||
|
INVALID_FIELD_MESSAGE_RE.test(message),
|
||||||
|
);
|
||||||
|
|
||||||
throw new AppError("INTERNAL_ERROR", detailedMessage);
|
throw new AppError("INTERNAL_ERROR", detailedMessage);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import {
|
import {
|
||||||
getKeywordDataProvider,
|
getKeywordDataProvider,
|
||||||
|
getLanguageOptions,
|
||||||
isSupportedLanguageCode,
|
isSupportedLanguageCode,
|
||||||
} from "@/shared/keyword-locations";
|
} from "@/shared/keyword-locations";
|
||||||
|
|
||||||
@ -36,6 +37,32 @@ export function assertLabsLocationCode(locationCode: number | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards Labs-backed tools against a language DataForSEO doesn't serve for the
|
||||||
|
* chosen location. A mismatched pair (e.g. language_code="ru" for the United
|
||||||
|
* States) is otherwise rejected as an opaque *charged* "Invalid Field:
|
||||||
|
* 'language_code'." task failure, so validate the pair first (cost 0). Only
|
||||||
|
* Labs locations have authoritative per-location language lists; Google Ads
|
||||||
|
* locations are left to the metering safety net.
|
||||||
|
*/
|
||||||
|
export function assertLanguageForLocation(
|
||||||
|
locationCode: number | undefined,
|
||||||
|
languageCode: string | undefined,
|
||||||
|
) {
|
||||||
|
if (languageCode == null) return;
|
||||||
|
const resolvedLocation = locationCode ?? DEFAULT_LOCATION_CODE;
|
||||||
|
if (getKeywordDataProvider(resolvedLocation) !== "labs") return;
|
||||||
|
const options = getLanguageOptions(resolvedLocation);
|
||||||
|
if (!options.some((option) => option.code === languageCode)) {
|
||||||
|
throw new AppError(
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
`Language '${languageCode}' is not available for this location. Available: ${options
|
||||||
|
.map((option) => option.code)
|
||||||
|
.join(", ")}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const languageCodeSchema = z
|
export const languageCodeSchema = z
|
||||||
.string()
|
.string()
|
||||||
.refine(isSupportedLanguageCode, {
|
.refine(isSupportedLanguageCode, {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
DEFAULT_LOCATION_CODE,
|
DEFAULT_LOCATION_CODE,
|
||||||
|
assertLanguageForLocation,
|
||||||
languageCodeSchema,
|
languageCodeSchema,
|
||||||
locationCodeSchema,
|
locationCodeSchema,
|
||||||
projectIdSchema,
|
projectIdSchema,
|
||||||
@ -828,6 +829,7 @@ export const getKeywordMetricsTool = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(async (args: GetKeywordMetricsArgs, context) => {
|
handler: withMcpProjectAuth(async (args: GetKeywordMetricsArgs, context) => {
|
||||||
|
assertLanguageForLocation(args.locationCode, args.languageCode);
|
||||||
const client = createDataforseoClient(context.billing);
|
const client = createDataforseoClient(context.billing);
|
||||||
const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE;
|
const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE;
|
||||||
const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
|
const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
DEFAULT_LOCATION_CODE,
|
DEFAULT_LOCATION_CODE,
|
||||||
assertLabsLocationCode,
|
assertLabsLocationCode,
|
||||||
|
assertLanguageForLocation,
|
||||||
languageCodeSchema,
|
languageCodeSchema,
|
||||||
locationCodeSchema,
|
locationCodeSchema,
|
||||||
projectIdSchema,
|
projectIdSchema,
|
||||||
@ -59,6 +60,7 @@ export const getDomainKeywordSuggestionsTool = {
|
|||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(async (args: Args, context) => {
|
handler: withMcpProjectAuth(async (args: Args, context) => {
|
||||||
assertLabsLocationCode(args.locationCode);
|
assertLabsLocationCode(args.locationCode);
|
||||||
|
assertLanguageForLocation(args.locationCode, args.languageCode);
|
||||||
const keywords = await DomainService.getSuggestedKeywords(
|
const keywords = await DomainService.getSuggestedKeywords(
|
||||||
{
|
{
|
||||||
domain: args.domain,
|
domain: args.domain,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
DEFAULT_LOCATION_CODE,
|
DEFAULT_LOCATION_CODE,
|
||||||
assertLabsLocationCode,
|
assertLabsLocationCode,
|
||||||
|
assertLanguageForLocation,
|
||||||
languageCodeSchema,
|
languageCodeSchema,
|
||||||
locationCodeSchema,
|
locationCodeSchema,
|
||||||
projectIdSchema,
|
projectIdSchema,
|
||||||
@ -52,6 +53,7 @@ export const getDomainOverviewTool = {
|
|||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(async (args: Args, context) => {
|
handler: withMcpProjectAuth(async (args: Args, context) => {
|
||||||
assertLabsLocationCode(args.locationCode);
|
assertLabsLocationCode(args.locationCode);
|
||||||
|
assertLanguageForLocation(args.locationCode, args.languageCode);
|
||||||
const result = await DomainService.getOverview(
|
const result = await DomainService.getOverview(
|
||||||
{
|
{
|
||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
DEFAULT_LOCATION_CODE,
|
DEFAULT_LOCATION_CODE,
|
||||||
|
assertLanguageForLocation,
|
||||||
languageCodeSchema,
|
languageCodeSchema,
|
||||||
locationCodeSchema,
|
locationCodeSchema,
|
||||||
projectIdSchema,
|
projectIdSchema,
|
||||||
@ -107,6 +108,7 @@ export const researchKeywordsTool = {
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
args.seeds.map(async (item) => {
|
args.seeds.map(async (item) => {
|
||||||
try {
|
try {
|
||||||
|
assertLanguageForLocation(item.locationCode, item.languageCode);
|
||||||
const data = await KeywordResearchService.research(
|
const data = await KeywordResearchService.research(
|
||||||
{
|
{
|
||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
|
|||||||
@ -13,6 +13,11 @@ import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
|||||||
|
|
||||||
const AUTUMN_EVENTS_LIST_URL = "https://api.useautumn.com/v1/events.list";
|
const AUTUMN_EVENTS_LIST_URL = "https://api.useautumn.com/v1/events.list";
|
||||||
const EVENT_PAGE_LIMIT = 1000;
|
const EVENT_PAGE_LIMIT = 1000;
|
||||||
|
// Autumn rate-limits events.list; back off on 429 before giving up.
|
||||||
|
const AUTUMN_MAX_RETRIES = 3;
|
||||||
|
const AUTUMN_RETRY_BACKOFF_MS = 250;
|
||||||
|
// Cap a server-supplied Retry-After so a bogus value can't stall the Worker.
|
||||||
|
const AUTUMN_MAX_RETRY_DELAY_MS = 5000;
|
||||||
const BILLING_USAGE_FEATURE_IDS = [
|
const BILLING_USAGE_FEATURE_IDS = [
|
||||||
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||||
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||||
@ -84,28 +89,42 @@ async function fetchAutumnEventsPage(args: {
|
|||||||
start: number;
|
start: number;
|
||||||
}): Promise<{ list: BillingUsageEvent[]; hasMore: boolean }> {
|
}): Promise<{ list: BillingUsageEvent[]; hasMore: boolean }> {
|
||||||
const secretKey = await getRequiredEnvValue("AUTUMN_SECRET_KEY");
|
const secretKey = await getRequiredEnvValue("AUTUMN_SECRET_KEY");
|
||||||
const response = await fetch(AUTUMN_EVENTS_LIST_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
Authorization: `Bearer ${secretKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
customer_id: args.customerId,
|
|
||||||
custom_range: {
|
|
||||||
end: args.end,
|
|
||||||
start: args.start,
|
|
||||||
},
|
|
||||||
feature_id: BILLING_USAGE_FEATURE_IDS,
|
|
||||||
limit: EVENT_PAGE_LIMIT,
|
|
||||||
offset: args.offset,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
let response: Response;
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
response = await fetch(AUTUMN_EVENTS_LIST_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${secretKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer_id: args.customerId,
|
||||||
|
custom_range: {
|
||||||
|
end: args.end,
|
||||||
|
start: args.start,
|
||||||
|
},
|
||||||
|
feature_id: BILLING_USAGE_FEATURE_IDS,
|
||||||
|
limit: EVENT_PAGE_LIMIT,
|
||||||
|
offset: args.offset,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) break;
|
||||||
|
|
||||||
|
if (response.status === 429 && attempt < AUTUMN_MAX_RETRIES) {
|
||||||
|
const retryAfterHeader = response.headers.get("Retry-After");
|
||||||
|
const retryAfter = retryAfterHeader ? Number(retryAfterHeader) : NaN;
|
||||||
|
const delayMs = Number.isFinite(retryAfter)
|
||||||
|
? Math.min(retryAfter * 1000, AUTUMN_MAX_RETRY_DELAY_MS)
|
||||||
|
: AUTUMN_RETRY_BACKOFF_MS * (attempt + 1);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
"INTERNAL_ERROR",
|
response.status === 429 ? "RATE_LIMITED" : "INTERNAL_ERROR",
|
||||||
`Autumn events.list failed with status ${response.status}`,
|
`Autumn events.list failed with status ${response.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user