fix: reduce maxDuration to 60s for Vercel Hobby plan compatibility
Routes with maxDuration=300 were silently failing on Vercel's edge routing layer - requests never reached the serverless function (no logs appeared). Vercel Hobby plan has a 60s max function duration. Affected routes: - /api/agents/[id]/conversation/[conversationId] - /api/agents/[username]/[uid]/conversation/[conversationId] - /api/mcp/[username]/[slug]/[transport] The issue caused these routes to hang indefinitely until client timeout.
This commit is contained in:
parent
9b6a4a625e
commit
4c5972adc4
5 changed files with 908 additions and 13 deletions
|
|
@ -27,7 +27,7 @@ const CHAT_RATE_LIMIT: RateLimitConfig = {
|
|||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes for long agentic runs
|
||||
export const maxDuration = 60; // Vercel Hobby plan max
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string; conversationId: string }>;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const CHAT_RATE_LIMIT: RateLimitConfig = {
|
|||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes for long agentic runs
|
||||
export const maxDuration = 60; // Vercel Hobby plan max
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ username: string; uid: string; conversationId: string }>;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { handleInitialize, handleToolsCall, handleToolsList } from '~/lib/mcp/ha
|
|||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
export const maxDuration = 60; // Vercel Hobby plan max
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ username: string; slug: string; transport: string }>;
|
||||
|
|
|
|||
903
docs/debug-reports/conversation-route-timeout-issue.md
Normal file
903
docs/debug-reports/conversation-route-timeout-issue.md
Normal file
|
|
@ -0,0 +1,903 @@
|
|||
# Next.js 16 API Route Timeout Issue - Comprehensive Debug Report
|
||||
|
||||
**Date:** 2026-01-09
|
||||
**Project:** TPMJS (tpmjs.com)
|
||||
**Issue:** API routes at `/api/agents/[id]/conversation/[conversationId]` timeout without any Vercel function logs
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Problem Summary](#problem-summary)
|
||||
2. [Environment Details](#environment-details)
|
||||
3. [Symptoms](#symptoms)
|
||||
4. [Working vs Non-Working Endpoints](#working-vs-non-working-endpoints)
|
||||
5. [Directory Structure](#directory-structure)
|
||||
6. [Configuration Files](#configuration-files)
|
||||
7. [Route File Contents](#route-file-contents)
|
||||
8. [Investigation Timeline](#investigation-timeline)
|
||||
9. [Hypotheses Tested](#hypotheses-tested)
|
||||
10. [Reproduction Steps](#reproduction-steps)
|
||||
11. [Key Observations](#key-observations)
|
||||
12. [Potential Causes](#potential-causes)
|
||||
|
||||
---
|
||||
|
||||
## Problem Summary
|
||||
|
||||
Requests to `/api/agents/[id]/conversation/[conversationId]` (both GET and POST) hang indefinitely and timeout after 30-60 seconds. **Critically, no logs appear in Vercel for these requests** - the requests never seem to reach the serverless function at all.
|
||||
|
||||
Other API routes work perfectly fine, including routes with similar structure like `/api/agents/[id]` and `/api/agents/[id]/conversations`.
|
||||
|
||||
---
|
||||
|
||||
## Environment Details
|
||||
|
||||
### Framework & Runtime
|
||||
- **Next.js Version:** 16.0.8
|
||||
- **Build System:** Turbopack (default in Next.js 16)
|
||||
- **React Version:** 19.0.0
|
||||
- **Node.js Runtime:** nodejs (specified in route)
|
||||
- **TypeScript Version:** 5.9.3
|
||||
|
||||
### Deployment
|
||||
- **Platform:** Vercel
|
||||
- **Region:** Auto (Vercel default)
|
||||
- **Monorepo:** Turborepo with pnpm
|
||||
|
||||
### Database
|
||||
- **Database:** Neon PostgreSQL
|
||||
- **ORM:** Prisma 6.19.0
|
||||
- **Connection:** Working (verified via other endpoints)
|
||||
|
||||
### Key Dependencies (from apps/web/package.json)
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.2",
|
||||
"@ai-sdk/google": "^3.0.2",
|
||||
"@ai-sdk/groq": "^3.0.2",
|
||||
"@ai-sdk/mistral": "^3.0.2",
|
||||
"@ai-sdk/openai": "3.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/blob": "^2.0.0",
|
||||
"@vercel/kv": "^3.0.0",
|
||||
"ai": "6.0.3",
|
||||
"better-auth": "^1.4.10",
|
||||
"next": "^16.0.8",
|
||||
"prisma": "^6.19.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Symptoms
|
||||
|
||||
1. **GET/POST requests timeout** - No response after 30-60 seconds
|
||||
2. **No Vercel logs** - Requests don't appear in Vercel function logs at all
|
||||
3. **No errors** - No build errors, no runtime errors, nothing
|
||||
4. **Route appears in build output** - Listed as dynamic function (ƒ)
|
||||
5. **Similar routes work** - `/api/agents/[id]` and `/api/agents/[id]/conversations` work perfectly
|
||||
|
||||
---
|
||||
|
||||
## Working vs Non-Working Endpoints
|
||||
|
||||
### Working Endpoints (respond in < 1 second)
|
||||
|
||||
| Endpoint | Response Time | Logs in Vercel |
|
||||
|----------|--------------|----------------|
|
||||
| `GET /api/health` | ~0.74s | ✅ Yes |
|
||||
| `GET /api/agents/cmjx48ojx0001la04lbhn98pz` | ~0.40s | ✅ Yes |
|
||||
| `GET /api/agents/cmjx48ojx0001la04lbhn98pz/conversations` | ~0.50s | ✅ Yes |
|
||||
| `GET /api/tools` | ~0.80s | ✅ Yes |
|
||||
|
||||
### Non-Working Endpoints (timeout, no logs)
|
||||
|
||||
| Endpoint | Result | Logs in Vercel |
|
||||
|----------|--------|----------------|
|
||||
| `GET /api/agents/[id]/conversation/[conversationId]` | ❌ Timeout | ❌ No logs |
|
||||
| `POST /api/agents/[id]/conversation/[conversationId]` | ❌ Timeout | ❌ No logs |
|
||||
| `GET /api/agents/[username]/[uid]/conversation/[conversationId]` | ❌ Timeout | ❌ No logs |
|
||||
| `POST /api/agents/[username]/[uid]/conversation/[conversationId]` | ❌ Timeout | ❌ No logs |
|
||||
|
||||
### Pattern Identified
|
||||
All routes with the path pattern `*/conversation/[conversationId]` fail. Routes with `*/conversations` (plural, no nested param) work.
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
apps/web/src/app/api/agents/
|
||||
├── route.ts ✅ Works
|
||||
├── [id]/
|
||||
│ ├── route.ts ✅ Works
|
||||
│ ├── like/route.ts ✅ Works
|
||||
│ ├── tools/route.ts ✅ Works
|
||||
│ ├── tools/[toolId]/route.ts ✅ Works (nested dynamic)
|
||||
│ ├── logs/route.ts ✅ Works
|
||||
│ ├── clone/route.ts ✅ Works
|
||||
│ ├── conversations/route.ts ✅ Works
|
||||
│ ├── collections/route.ts ✅ Works
|
||||
│ ├── collections/[collectionId]/route.ts ✅ Works (nested dynamic)
|
||||
│ ├── stats/route.ts ✅ Works
|
||||
│ └── conversation/
|
||||
│ └── [conversationId]/
|
||||
│ └── route.ts ❌ FAILS - No logs
|
||||
└── [username]/
|
||||
└── [uid]/
|
||||
└── conversation/
|
||||
└── [conversationId]/
|
||||
└── route.ts ❌ FAILS - No logs
|
||||
```
|
||||
|
||||
**Key Observation:** Other nested dynamic routes work (`[id]/tools/[toolId]`, `[id]/collections/[collectionId]`), but `[id]/conversation/[conversationId]` does not.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Root vercel.json
|
||||
```json
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"buildCommand": "pnpm run build:web",
|
||||
"outputDirectory": "apps/web/.next",
|
||||
"installCommand": "pnpm install",
|
||||
"framework": "nextjs",
|
||||
"git": {
|
||||
"deploymentEnabled": {
|
||||
"main": true
|
||||
}
|
||||
},
|
||||
"github": {
|
||||
"silent": false,
|
||||
"autoJobCancelation": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Strict-Transport-Security",
|
||||
"value": "max-age=31536000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"key": "X-Content-Type-Options",
|
||||
"value": "nosniff"
|
||||
},
|
||||
{
|
||||
"key": "X-Frame-Options",
|
||||
"value": "DENY"
|
||||
},
|
||||
{
|
||||
"key": "X-XSS-Protection",
|
||||
"value": "1; mode=block"
|
||||
},
|
||||
{
|
||||
"key": "Referrer-Policy",
|
||||
"value": "strict-origin-when-cross-origin"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/sync/changes",
|
||||
"schedule": "0 */4 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/sync/keyword",
|
||||
"schedule": "0 */6 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/sync/metrics",
|
||||
"schedule": "0 0 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/sync/stats-snapshot",
|
||||
"schedule": "0 0 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/sync/cleanup-activity",
|
||||
"schedule": "0 3 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### apps/web/vercel.json
|
||||
```json
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"buildCommand": "cd ../.. && pnpm run build:web",
|
||||
"installCommand": "cd ../.. && pnpm install"
|
||||
}
|
||||
```
|
||||
|
||||
### apps/web/next.config.ts
|
||||
```typescript
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@tpmjs/ui', '@tpmjs/utils', '@tpmjs/db', '@tpmjs/types', '@tpmjs/env'],
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: ['@tpmjs/package-executor'],
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/tools-ideas',
|
||||
destination: '/tool-ideas',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
```
|
||||
|
||||
### apps/web/src/middleware.ts
|
||||
```typescript
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// Protect /dashboard routes
|
||||
if (request.nextUrl.pathname.startsWith('/dashboard')) {
|
||||
// Check for session token - better-auth uses __Secure- prefix for HTTPS cookies
|
||||
const sessionToken =
|
||||
request.cookies.get('__Secure-better-auth.session_token') ||
|
||||
request.cookies.get('better-auth.session_token');
|
||||
if (!sessionToken) {
|
||||
return NextResponse.redirect(new URL('/sign-in', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*'],
|
||||
};
|
||||
```
|
||||
|
||||
**Note:** Middleware only matches `/dashboard/*` paths, not `/api/*` paths. Should not affect API routes.
|
||||
|
||||
---
|
||||
|
||||
## Route File Contents
|
||||
|
||||
### FAILING ROUTE: apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Agent Conversation Endpoint (ID-based version)
|
||||
*
|
||||
* POST: Send a message and stream the AI response
|
||||
* GET: Retrieve conversation history
|
||||
* DELETE: Delete a conversation
|
||||
*
|
||||
* This endpoint uses agent id directly for dashboard usage
|
||||
*/
|
||||
|
||||
import { decryptApiKey } from '@/lib/crypto/api-keys';
|
||||
import { Prisma, prisma } from '@tpmjs/db';
|
||||
import type { AIProvider } from '@tpmjs/types/agent';
|
||||
import { SendMessageSchema } from '@tpmjs/types/agent';
|
||||
import type { LanguageModel, ModelMessage } from 'ai';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { type RateLimitConfig, checkRateLimit } from '~/lib/rate-limit';
|
||||
|
||||
/**
|
||||
* Rate limit for chat messages: 30 requests per minute
|
||||
* This is stricter than default because chat involves expensive LLM calls
|
||||
*/
|
||||
const CHAT_RATE_LIMIT: RateLimitConfig = {
|
||||
limit: 30,
|
||||
windowSeconds: 60,
|
||||
};
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes for long agentic runs
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string; conversationId: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get AI provider SDK based on provider type
|
||||
*/
|
||||
async function getProviderModel(
|
||||
provider: AIProvider,
|
||||
modelId: string,
|
||||
apiKey: string
|
||||
): Promise<LanguageModel> {
|
||||
switch (provider) {
|
||||
case 'OPENAI': {
|
||||
const { createOpenAI } = await import('@ai-sdk/openai');
|
||||
return createOpenAI({ apiKey })(modelId);
|
||||
}
|
||||
case 'ANTHROPIC': {
|
||||
const { createAnthropic } = await import('@ai-sdk/anthropic');
|
||||
return createAnthropic({ apiKey })(modelId);
|
||||
}
|
||||
case 'GOOGLE': {
|
||||
const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
|
||||
return createGoogleGenerativeAI({ apiKey })(modelId);
|
||||
}
|
||||
case 'GROQ': {
|
||||
const { createGroq } = await import('@ai-sdk/groq');
|
||||
return createGroq({ apiKey })(modelId);
|
||||
}
|
||||
case 'MISTRAL': {
|
||||
const { createMistral } = await import('@ai-sdk/mistral');
|
||||
return createMistral({ apiKey })(modelId);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/agents/[id]/conversation/[conversationId]
|
||||
* Send a message and stream the AI response via SSE
|
||||
*/
|
||||
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
|
||||
// Check rate limit first to prevent expensive LLM calls
|
||||
const rateLimitResponse = checkRateLimit(request, CHAT_RATE_LIMIT);
|
||||
if (rateLimitResponse) {
|
||||
return rateLimitResponse;
|
||||
}
|
||||
|
||||
const { id: agentId, conversationId } = await context.params;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = SendMessageSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch agent with all tool relations using agent ID
|
||||
const { fetchAgentWithTools, buildAgentTools } = await import('@/lib/agents/build-tools');
|
||||
const agent = await fetchAgentWithTools(agentId);
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// ... rest of implementation (streaming SSE response)
|
||||
} catch (error) {
|
||||
console.error('Failed to process message:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to process message',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/agents/[id]/conversation/[conversationId]
|
||||
* Retrieve conversation history with pagination
|
||||
*/
|
||||
export async function GET(request: NextRequest, context: RouteContext): Promise<NextResponse> {
|
||||
const { id: agentId, conversationId } = await context.params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '50', 10), 100);
|
||||
const before = searchParams.get('before');
|
||||
const after = searchParams.get('after');
|
||||
|
||||
try {
|
||||
// Fetch agent by ID
|
||||
const agent = await prisma.agent.findUnique({
|
||||
where: { id: agentId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch conversation
|
||||
const conversation = await prisma.conversation.findUnique({
|
||||
where: {
|
||||
agentId_slug: {
|
||||
agentId: agent.id,
|
||||
slug: conversationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Conversation not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// ... rest of implementation (simple Prisma queries)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch conversation:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch conversation' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/agents/[id]/conversation/[conversationId]
|
||||
* Delete a conversation
|
||||
*/
|
||||
export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
|
||||
const { id: agentId, conversationId } = await context.params;
|
||||
|
||||
try {
|
||||
// Fetch agent by ID
|
||||
const agent = await prisma.agent.findUnique({
|
||||
where: { id: agentId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete conversation (messages cascade)
|
||||
await prisma.conversation.deleteMany({
|
||||
where: {
|
||||
agentId: agent.id,
|
||||
slug: conversationId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { deleted: true },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete conversation' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WORKING ROUTE: apps/web/src/app/api/agents/[id]/conversations/route.ts (plural)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Agent Conversations List Endpoint
|
||||
*
|
||||
* GET: List all conversations for an agent
|
||||
*/
|
||||
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 30;
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/agents/[id]/conversations
|
||||
* List all conversations for an agent (accepts id or uid)
|
||||
*/
|
||||
export async function GET(request: NextRequest, context: RouteContext): Promise<NextResponse> {
|
||||
const { id: idOrUid } = await context.params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '20', 10), 100);
|
||||
const offset = Number.parseInt(searchParams.get('offset') || '0', 10);
|
||||
|
||||
try {
|
||||
// Fetch agent by id or uid
|
||||
const agent = await prisma.agent.findFirst({
|
||||
where: {
|
||||
OR: [{ id: idOrUid }, { uid: idOrUid }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch conversations with message count
|
||||
const conversations = await prisma.conversation.findMany({
|
||||
where: { agentId: agent.id },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: limit + 1,
|
||||
skip: offset,
|
||||
include: {
|
||||
_count: {
|
||||
select: { messages: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hasMore = conversations.length > limit;
|
||||
const data = hasMore ? conversations.slice(0, limit) : conversations;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: data.map((c) => ({
|
||||
id: c.id,
|
||||
slug: c.slug,
|
||||
title: c.title,
|
||||
messageCount: c._count.messages,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
})),
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
hasMore,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch conversations' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WORKING ROUTE: apps/web/src/app/api/health/route.ts
|
||||
|
||||
```typescript
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { checkRateLimit } from '~/lib/rate-limit';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
* Simple health check endpoint that doesn't touch the database
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// Check rate limit
|
||||
const rateLimitResponse = checkRateLimit(request);
|
||||
if (rateLimitResponse) {
|
||||
return rateLimitResponse;
|
||||
}
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
build: {
|
||||
// Vercel provides these at runtime
|
||||
commitSha: process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) || 'local',
|
||||
commitMessage: process.env.VERCEL_GIT_COMMIT_MESSAGE || 'local',
|
||||
deploymentUrl: process.env.VERCEL_URL || 'localhost',
|
||||
},
|
||||
env: {
|
||||
hasDatabase: !!process.env.DATABASE_URL,
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### WORKING ROUTE: apps/web/src/app/api/agents/[id]/route.ts
|
||||
|
||||
```typescript
|
||||
import { Prisma, prisma } from '@tpmjs/db';
|
||||
import { UpdateAgentSchema } from '@tpmjs/types/agent';
|
||||
import { headers } from 'next/headers';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { logActivity } from '~/lib/activity';
|
||||
import {
|
||||
apiConflict,
|
||||
apiForbidden,
|
||||
apiInternalError,
|
||||
apiNotFound,
|
||||
apiSuccess,
|
||||
apiUnauthorized,
|
||||
apiValidationError,
|
||||
} from '~/lib/api-response';
|
||||
import { auth } from '~/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/agents/[id]
|
||||
* Get a single agent's details
|
||||
*/
|
||||
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const { id } = await context.params;
|
||||
|
||||
const agent = await prisma.agent.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
collections: {
|
||||
include: {
|
||||
collection: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
_count: { select: { tools: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { position: 'asc' },
|
||||
take: 50,
|
||||
},
|
||||
tools: {
|
||||
include: {
|
||||
tool: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
package: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
category: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { position: 'asc' },
|
||||
take: 100,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
tools: true,
|
||||
collections: true,
|
||||
conversations: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
return apiNotFound('Agent', requestId);
|
||||
}
|
||||
|
||||
// Check access - owner or public
|
||||
const isOwner = session?.user?.id === agent.userId;
|
||||
if (!isOwner && !agent.isPublic) {
|
||||
return apiForbidden('Access denied', requestId);
|
||||
}
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
...agent,
|
||||
isOwner,
|
||||
toolCount: agent._count.tools,
|
||||
collectionCount: agent._count.collections,
|
||||
conversationCount: agent._count.conversations,
|
||||
// ... mapping
|
||||
},
|
||||
{ requestId }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get agent:', error);
|
||||
return apiInternalError('Failed to get agent', requestId);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH and DELETE handlers also work fine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Investigation Timeline
|
||||
|
||||
### Initial State
|
||||
- Routes were working previously (before refactoring)
|
||||
- User refactored code, issue started
|
||||
|
||||
### Fix Attempt 1: Rate Limiter Timeout
|
||||
- **Hypothesis:** `checkRateLimitDistributed` using `@vercel/kv` was hanging
|
||||
- **Action:** Added 2-second timeout to KV calls
|
||||
- **Result:** ❌ Still timing out
|
||||
|
||||
### Fix Attempt 2: Switch to Sync Rate Limiter
|
||||
- **Hypothesis:** Async KV rate limiter had connection issues
|
||||
- **Action:** Changed from `checkRateLimitDistributed` to `checkRateLimit` (sync, in-memory)
|
||||
- **Result:** ❌ Still timing out
|
||||
|
||||
### Current State
|
||||
- Route builds successfully
|
||||
- Route appears in Vercel build output as dynamic function
|
||||
- Requests timeout with no logs
|
||||
- Other similar routes work fine
|
||||
|
||||
---
|
||||
|
||||
## Hypotheses Tested
|
||||
|
||||
| # | Hypothesis | Test | Result |
|
||||
|---|------------|------|--------|
|
||||
| 1 | Rate limiter hanging | Added timeout | ❌ Failed |
|
||||
| 2 | KV connection issue | Switched to sync limiter | ❌ Failed |
|
||||
| 3 | Database connection | Tested with /api/health | ✅ DB works |
|
||||
| 4 | Prisma cold start | Tested other Prisma routes | ✅ Other routes work |
|
||||
| 5 | Build issue | Checked build output | ✅ Route is built |
|
||||
| 6 | Middleware blocking | Checked middleware config | ✅ Only matches /dashboard/* |
|
||||
|
||||
---
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
### 1. Test Working Endpoint
|
||||
```bash
|
||||
curl -s 'https://tpmjs.com/api/health' -w "\nTime: %{time_total}s"
|
||||
# Returns: {"status":"ok",...} in ~0.7s
|
||||
```
|
||||
|
||||
### 2. Test Working Agent Endpoint
|
||||
```bash
|
||||
curl -s 'https://tpmjs.com/api/agents/cmjx48ojx0001la04lbhn98pz' -w "\nTime: %{time_total}s"
|
||||
# Returns: Agent data in ~0.4s
|
||||
```
|
||||
|
||||
### 3. Test Working Conversations (plural) Endpoint
|
||||
```bash
|
||||
curl -s 'https://tpmjs.com/api/agents/cmjx48ojx0001la04lbhn98pz/conversations' -w "\nTime: %{time_total}s"
|
||||
# Returns: Conversations list in ~0.5s
|
||||
```
|
||||
|
||||
### 4. Test Failing Conversation (singular) Endpoint - GET
|
||||
```bash
|
||||
curl -s 'https://tpmjs.com/api/agents/cmjx48ojx0001la04lbhn98pz/conversation/conv-1767892050799-rk8eaqwhr' \
|
||||
-w "\nTime: %{time_total}s" \
|
||||
--max-time 60
|
||||
|
||||
# Result: Hangs until timeout, no response
|
||||
# Vercel logs: NO ENTRY for this request
|
||||
```
|
||||
|
||||
### 5. Test Failing Conversation Endpoint - POST
|
||||
```bash
|
||||
curl 'https://tpmjs.com/api/agents/cmjx48ojx0001la04lbhn98pz/conversation/conv-1767892050799-rk8eaqwhr' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-raw '{"message":"hi"}' \
|
||||
-w "\nTime: %{time_total}s" \
|
||||
--max-time 60
|
||||
|
||||
# Result: Hangs until timeout, no response
|
||||
# Vercel logs: NO ENTRY for this request
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Observations
|
||||
|
||||
### 1. No Vercel Logs
|
||||
The most critical observation: **requests to the failing route do not appear in Vercel logs at all**. This suggests the request is not reaching the serverless function.
|
||||
|
||||
### 2. Route Pattern Difference
|
||||
- ❌ `/api/agents/[id]/conversation/[conversationId]` - Fails
|
||||
- ✅ `/api/agents/[id]/conversations` - Works
|
||||
- ✅ `/api/agents/[id]/collections/[collectionId]` - Works
|
||||
- ✅ `/api/agents/[id]/tools/[toolId]` - Works
|
||||
|
||||
The pattern `conversation/[conversationId]` fails while similar patterns like `tools/[toolId]` work.
|
||||
|
||||
### 3. Build Output Shows Route
|
||||
```
|
||||
Route (app)
|
||||
├ ƒ /api/agents/[id]/conversation/[conversationId] ← Listed as dynamic function
|
||||
```
|
||||
|
||||
### 4. Singular vs Plural
|
||||
- `conversation` (singular) + nested param = ❌ Fails
|
||||
- `conversations` (plural) = ✅ Works
|
||||
|
||||
### 5. Multiple Failing Routes
|
||||
Both route variants fail:
|
||||
- `/api/agents/[id]/conversation/[conversationId]/route.ts`
|
||||
- `/api/agents/[username]/[uid]/conversation/[conversationId]/route.ts`
|
||||
|
||||
---
|
||||
|
||||
## Potential Causes
|
||||
|
||||
### 1. Next.js 16 / Turbopack Routing Bug
|
||||
There may be a bug in Next.js 16 or Turbopack with:
|
||||
- Specific path patterns
|
||||
- Nested dynamic segments with certain names
|
||||
- Routes with "conversation" in the path
|
||||
|
||||
### 2. Vercel Edge Routing Issue
|
||||
Vercel's edge network might have issues routing to:
|
||||
- Deeply nested dynamic routes
|
||||
- Routes with specific naming patterns
|
||||
- Routes with certain configurations
|
||||
|
||||
### 3. Build Artifact Corruption
|
||||
The route might:
|
||||
- Build successfully but not deploy correctly
|
||||
- Have a corrupted manifest entry
|
||||
- Be missing from the serverless function bundle
|
||||
|
||||
### 4. Route Conflict
|
||||
There might be an internal conflict between:
|
||||
- `conversation` (singular) and `conversations` (plural)
|
||||
- The nested dynamic segment pattern
|
||||
- Some internal Next.js routing logic
|
||||
|
||||
### 5. Framework Bug with Dynamic Imports
|
||||
The route uses dynamic imports (`await import()`) which might cause issues with:
|
||||
- Turbopack bundling
|
||||
- Serverless function generation
|
||||
- Module resolution
|
||||
|
||||
---
|
||||
|
||||
## Recent Commits
|
||||
|
||||
```
|
||||
5bf4260 fix: use sync rate limiter for conversation routes
|
||||
05ccdc6 fix: add timeout to Vercel KV rate limiter to prevent hanging
|
||||
4db1522 fix: add ID-based conversation route for dashboard chat
|
||||
7fe1027 fix: add root-level health and execute-tool routes for TPMJS compatibility
|
||||
63d408b fix(executor): use correct Vercel Sandbox SDK API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Questions for Further Investigation
|
||||
|
||||
1. Is this a known Next.js 16 / Turbopack issue?
|
||||
2. Does the route name "conversation" conflict with any internal Next.js routing?
|
||||
3. Is there a Vercel-specific issue with nested dynamic segments?
|
||||
4. Why do other nested dynamic routes (`tools/[toolId]`, `collections/[collectionId]`) work?
|
||||
5. Is there something specific about the combination of:
|
||||
- Multiple dynamic segments (`[id]` + `[conversationId]`)
|
||||
- The word "conversation"
|
||||
- The route file size/complexity
|
||||
|
||||
---
|
||||
|
||||
## Files Affected
|
||||
|
||||
- `apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts`
|
||||
- `apps/web/src/app/api/agents/[username]/[uid]/conversation/[conversationId]/route.ts`
|
||||
|
||||
## Related Working Files (for comparison)
|
||||
|
||||
- `apps/web/src/app/api/agents/[id]/conversations/route.ts`
|
||||
- `apps/web/src/app/api/agents/[id]/tools/[toolId]/route.ts`
|
||||
- `apps/web/src/app/api/agents/[id]/collections/[collectionId]/route.ts`
|
||||
- `apps/web/src/app/api/agents/[id]/route.ts`
|
||||
- `apps/web/src/app/api/health/route.ts`
|
||||
|
|
@ -88,17 +88,9 @@ export async function POST(req: NextRequest): Promise<NextResponse<ExecuteToolRe
|
|||
});
|
||||
}
|
||||
|
||||
// 2) Build environment setup for the script
|
||||
const envSetup = env
|
||||
? Object.entries(env)
|
||||
.map(([key, value]) => `process.env[${JSON.stringify(key)}] = ${JSON.stringify(value)};`)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
// 3) Write the execution script (CommonJS for require())
|
||||
// 2) Write the execution script (CommonJS for require())
|
||||
// Note: env vars are passed via runCommand's env option, not embedded in script
|
||||
const script = `
|
||||
${envSetup}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const pkg = require(${JSON.stringify(packageName)});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue