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
2 changes: 1 addition & 1 deletion packages/app/server/src/clients/gpt-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ async function makeRequest(useStreaming: boolean = false) {
try {
// Initialize OpenAI client with custom baseURL
const openai = new OpenAI({
baseURL: 'http://localhost:3070/5b20a7e2-f4eb-4879-b889-dc19148a6b06',
baseURL: 'http://localhost:3070',
apiKey: env.ECHO_API_KEY, // Required by the client but not used with local server
});

Expand Down
2 changes: 1 addition & 1 deletion packages/app/server/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const env = createEnv({
X402RS_FACILITATOR_METHOD_PREFIX: z.string().optional(),
PAYAI_FACILITATOR_BASE_URL: z.string().url().optional(),
PAYAI_FACILITATOR_METHOD_PREFIX: z.string().optional(),
FACILITATOR_REQUEST_TIMEOUT: z.coerce.number().default(20000),
FACILITATOR_REQUEST_TIMEOUT: z.coerce.number().default(60000),

// API Keys - Providers
ECHO_API_KEY: z.string().optional(),
Expand Down
116 changes: 76 additions & 40 deletions packages/app/server/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { calculateRefundAmount } from 'utils';
import { checkBalance } from 'services/BalanceCheckService';
import { prisma } from 'server';
import { makeProxyPassthroughRequest } from 'services/ProxyPassthroughService';
import logger from 'logger';
import logger, { logMetric } from 'logger';
import { ProviderType } from 'providers/ProviderType';
import { settle } from 'handlers/settle';
import { finalize } from 'handlers/finalize';
Expand All @@ -24,55 +24,91 @@ export async function handleX402Request({
if (isPassthroughProxyRoute) {
return await makeProxyPassthroughRequest(req, res, provider, headers);
}
const settleResult = await settle(req, res, headers, maxCost);
if (!settleResult) {
return;
}

const { payload, paymentAmountDecimal } = settleResult;
const settlePromise = settle(req, res, headers, maxCost);

try {
const transactionResult = await modelRequestService.executeModelRequest(
req,
res,
headers,
provider,
isStream
);
const transaction = transactionResult.transaction;
if (provider.getType() === ProviderType.OPENAI_VIDEOS) {
await prisma.videoGenerationX402.create({
data: {
videoId: transaction.metadata.providerId,
wallet: payload.authorization.from,
cost: transaction.rawTransactionCost,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 1),
},
});
}
const modelResultPromise = modelRequestService
.executeModelRequest(req, res, headers, provider, isStream)
.then((data) => ({ success: true as const, data }))
.catch((error) => ({ success: false as const, error: error as Error }));

const [settleResult, modelResult] = await Promise.all([
settlePromise,
modelResultPromise,
]);

// Case 1: Settle failed and model failed
if (!settleResult && !modelResult.success) {
return;
}

// Case 2: Settle failed but model succeeded
if (!settleResult && modelResult.success) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Case 2 (settle failed but model succeeded) calls handleResolveResponse after settle has already sent a 402 response, which will cause an Express error when trying to send a second response to the same request.

View Details
📝 Patch Details
diff --git a/packages/app/server/src/handlers.ts b/packages/app/server/src/handlers.ts
index 131d35d9..fb53c578 100644
--- a/packages/app/server/src/handlers.ts
+++ b/packages/app/server/src/handlers.ts
@@ -54,11 +54,7 @@ export async function handleX402Request({
       provider: provider.getType(),
       url: req.url,
     });
-    modelRequestService.handleResolveResponse(
-      res,
-      isStream,
-      data
-    );
+    // Note: settle already sent a 402 response, so we should not attempt to send another response
     return;
   }
 

Analysis

Double response error in handleX402Request Case 2

What fails: In handleX402Request() (packages/app/server/src/handlers.ts, lines 46-62), when settle fails but the model request succeeds (Case 2), the code calls modelRequestService.handleResolveResponse() after settle has already sent a 402 response via buildX402Response(), causing Express to throw "Error: Can't set headers after they are sent to the client".

How to reproduce:

  1. Send an X402 request where payment settlement fails (e.g., invalid payment authorization)
  2. Simultaneously, the model request succeeds
  3. The settle handler calls buildX402Response(req, res, maxCost) which sends res.status(402).json(resBody)
  4. Case 2 then attempts to call modelRequestService.handleResolveResponse(res, isStream, data) which calls either res.json(data) (non-streaming) or res.end() (streaming)
  5. Express throws error because headers were already sent

Result: Error thrown and logged by error handler: "Error: Can't set headers after they are sent to the client". While the 402 response reaches the client (and the error handler checks res.headersSent before attempting another response), this is incorrect behavior - no error should be thrown since the 402 response is the correct outcome.

Expected: When settle fails and sends a 402 response indicating payment is required, no subsequent response should be attempted. The 402 response should be the final response to the client.

Root cause: Express.js specification requires exactly one response per HTTP request. Once response headers are sent, attempting to set additional headers or send another response violates this protocol and throws an error.

const { data } = modelResult;
logger.error('Settle failed but model request succeeded', {
provider: provider.getType(),
url: req.url,
metadata: data.transaction.metadata,
});
logMetric('x402_request_settle_failed_model_request_succeeded', 1, {
provider: provider.getType(),
url: req.url,
});
modelRequestService.handleResolveResponse(
res,
isStream,
transactionResult.data
data
);
return;
}

logger.info(
`Creating X402 transaction for app. Metadata: ${JSON.stringify(transaction.metadata)}`
);
const transactionCosts =
await x402AuthenticationService.createX402Transaction(transaction);

await finalize(
paymentAmountDecimal,
transactionCosts.rawTransactionCost,
transactionCosts.totalAppProfit,
transactionCosts.echoProfit,
payload
);
} catch (error) {
// At this point, settleResult is guaranteed to exist
if (!settleResult) {
return;
}

const { payload, paymentAmountDecimal } = settleResult;

// Case 3: Settle succeeded but model failed
if (!modelResult.success) {
await refund(paymentAmountDecimal, payload);
return;
}

// Case 4: Both settle and model succeeded
const transactionResult = modelResult.data;
const transaction = transactionResult.transaction;

if (provider.getType() === ProviderType.OPENAI_VIDEOS) {
await prisma.videoGenerationX402.create({
data: {
videoId: transaction.metadata.providerId,
wallet: payload.authorization.from,
cost: transaction.rawTransactionCost,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 1),
},
});
}

modelRequestService.handleResolveResponse(
res,
isStream,
transactionResult.data
);

logger.info(
`Creating X402 transaction for app. Metadata: ${JSON.stringify(transaction.metadata)}`
);
const transactionCosts =
await x402AuthenticationService.createX402Transaction(transaction);

await finalize(
paymentAmountDecimal,
transactionCosts.rawTransactionCost,
transactionCosts.totalAppProfit,
transactionCosts.echoProfit,
payload
);
}

export async function handleApiKeyRequest({
Expand Down
Loading