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
11 changes: 11 additions & 0 deletions .changeset/jolly-crabs-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@inkeep/agents-work-apps": patch
"@inkeep/agents-core": patch
"@inkeep/agents-api": patch
---

agents-core: Add isUniqueConstraintError and throwIfUniqueConstraintError helpers to normalize unique constraint error detection across PostgreSQL and Doltgres

agents-api: Fix duplicate resource creation returning 500 instead of 409 when Doltgres reports unique constraint violations as MySQL errno 1062

agents-work-apps: Fix concurrent user mapping creation returning 500 instead of succeeding silently when a duplicate mapping already exists
13 changes: 5 additions & 8 deletions agents-api/src/domains/manage/routes/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
TenantProjectAgentSubAgentParamsSchema,
TenantProjectIdParamsSchema,
TenantProjectParamsSchema,
throwIfUniqueConstraintError,
updateAgent,
} from '@inkeep/agents-core';
import { createProtectedRoute } from '@inkeep/agents-core/middleware';
Expand Down Expand Up @@ -249,14 +250,10 @@ app.openapi(

return c.json({ data: agent }, 201);
} catch (error: any) {
// Handle duplicate agent (PostgreSQL unique constraint violation)
if (error?.cause?.code === '23505') {
const agentId = validatedBody.id || 'unknown';
throw createApiError({
code: 'conflict',
message: `An agent with ID '${agentId}' already exists`,
});
}
throwIfUniqueConstraintError(
error,
`An agent with ID '${validatedBody.id || 'unknown'}' already exists`
);

// Re-throw other errors to be handled by the global error handler
throw error;
Expand Down
9 changes: 2 additions & 7 deletions agents-api/src/domains/manage/routes/artifactComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
PaginationQueryParamsSchema,
TenantProjectIdParamsSchema,
TenantProjectParamsSchema,
throwIfUniqueConstraintError,
updateArtifactComponent,
validatePropsAsJsonSchema,
} from '@inkeep/agents-core';
Expand Down Expand Up @@ -172,13 +173,7 @@ app.openapi(

return c.json({ data: artifactComponent }, 201);
} catch (error: any) {
// Handle duplicate artifact component (PostgreSQL unique constraint violation)
if (error?.cause?.code === '23505') {
throw createApiError({
code: 'conflict',
message: `Artifact component with ID '${finalId}' already exists`,
});
}
throwIfUniqueConstraintError(error, `Artifact component with ID '${finalId}' already exists`);

// Re-throw other errors to be handled by the global error handler
throw error;
Expand Down
8 changes: 2 additions & 6 deletions agents-api/src/domains/manage/routes/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
listBranchesForAgent,
TenantProjectAgentParamsSchema,
TenantProjectParamsSchema,
throwIfUniqueConstraintError,
} from '@inkeep/agents-core';
import { createProtectedRoute } from '@inkeep/agents-core/middleware';
import runDbClient from '../../../data/db/runDbClient';
Expand Down Expand Up @@ -190,12 +191,7 @@ app.openapi(
} catch (error: any) {
const message = error?.message || 'Unknown error';

if (message.includes('already exists')) {
throw createApiError({
code: 'conflict',
message: `Branch '${name}' already exists`,
});
}
throwIfUniqueConstraintError(error, `Branch '${name}' already exists`);

if (message.includes('cannot be empty') || message.includes('invalid')) {
throw createApiError({
Expand Down
9 changes: 2 additions & 7 deletions agents-api/src/domains/manage/routes/projectFull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
syncProjectToSpiceDb,
TenantParamsSchema,
TenantProjectParamsSchema,
throwIfUniqueConstraintError,
updateFullProjectServerSide,
} from '@inkeep/agents-core';
import { createProtectedRoute, registerAuthzMeta } from '@inkeep/agents-core/middleware';
Expand Down Expand Up @@ -193,14 +194,8 @@ app.openapi(

return c.json({ data: createdProject }, 201);
} catch (error: any) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Minor: Error logging at wrong level for expected user errors

Issue: logger.error() is called before checking for unique constraint errors, meaning expected duplicate creation attempts (which return 409 to users) are logged at ERROR level.

Why: Logging expected user errors at ERROR level creates noise in monitoring, potentially triggering unnecessary alerts and making it harder to spot real issues.

Fix: Move the error log after the unique constraint check, or log at INFO level for duplicates:

Suggested change
} catch (error: any) {
} catch (error: any) {
if (isUniqueConstraintError(error)) {
logger.info({ projectId: projectData.id }, 'Duplicate project creation attempted');
throwIfUniqueConstraintError(error, `Project with ID '${projectData.id}' already exists`);
}
logger.error({ error }, 'Error creating project');

Note: This would require importing isUniqueConstraintError separately from throwIfUniqueConstraintError.

Refs:

// Handle duplicate project creation (PostgreSQL unique constraint violation)
logger.error({ error }, 'Error creating project');
if (error?.cause?.code === '23505' || error?.message?.includes('already exists')) {
throw createApiError({
code: 'conflict',
message: `Project with ID '${projectData.id}' already exists`,
});
}
throwIfUniqueConstraintError(error, `Project with ID '${projectData.id}' already exists`);

// Handle SpiceDB sync failures — DB transactions rolled back since the
// exception propagated out of the transaction callbacks.
Expand Down
9 changes: 2 additions & 7 deletions agents-api/src/domains/manage/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
syncProjectToSpiceDb,
TenantIdParamsSchema,
TenantParamsSchema,
throwIfUniqueConstraintError,
updateProject,
} from '@inkeep/agents-core';
import { createProtectedRoute, inheritedManageTenantAuth } from '@inkeep/agents-core/middleware';
Expand Down Expand Up @@ -257,13 +258,7 @@ app.openapi(
201
);
} catch (error: any) {
// Handle duplicate project (PostgreSQL unique constraint violation)
if (error?.cause?.code === '23505' || error?.message?.includes('already exists')) {
throw createApiError({
code: 'conflict',
message: 'Project with this ID already exists',
});
}
throwIfUniqueConstraintError(error, 'Project with this ID already exists');
throw error;
}
}
Expand Down
4 changes: 2 additions & 2 deletions agents-api/src/domains/run/handlers/executionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getActiveAgentForConversation,
getInProcessFetch,
getTask,
isUniqueConstraintError,
type ModelSettings,
type Part,
type SendMessageResponse,
Expand Down Expand Up @@ -199,8 +200,7 @@ export class ExecutionHandler {
'Task created with metadata'
);
} catch (error: any) {
// Handle duplicate task (PostgreSQL unique constraint violation)
if (error?.cause?.code === '23505') {
if (isUniqueConstraintError(error)) {
logger.info(
{ taskId, error: error.message },
'Task already exists, fetching existing task'
Expand Down
106 changes: 106 additions & 0 deletions packages/agents-core/src/utils/__tests__/error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import { isUniqueConstraintError, throwIfUniqueConstraintError } from '../error';

describe('isUniqueConstraintError', () => {
describe('PostgreSQL unique violation (23505)', () => {
it('returns true when cause.code is 23505', () => {
const error = { cause: { code: '23505' }, message: 'Failed query: INSERT ...' };
expect(isUniqueConstraintError(error)).toBe(true);
});

it('returns false when cause.code is a different PG error code', () => {
const error = { cause: { code: '40001' }, message: 'Failed query: INSERT ...' };
expect(isUniqueConstraintError(error)).toBe(false);
});
});

describe('Doltgres MySQL errno 1062', () => {
it('returns true when cause.message contains 1062', () => {
const error = {
cause: { code: 'XX000', message: "1062: Duplicate entry 'x' for key 'PRIMARY'" },
message: 'Failed query: INSERT ...',
};
expect(isUniqueConstraintError(error)).toBe(true);
});

it('returns false when cause.message contains a different MySQL errno', () => {
const error = {
cause: { code: 'XX000', message: '1213: Deadlock found when trying to get lock' },
message: 'Failed query: INSERT ...',
};
expect(isUniqueConstraintError(error)).toBe(false);
});
});

describe('"already exists" message fallback', () => {
it('returns true when error message contains "already exists"', () => {
const error = { message: "Branch 'main' already exists" };
expect(isUniqueConstraintError(error)).toBe(true);
});

it('returns false when error message does not contain "already exists"', () => {
const error = { message: 'Branch creation failed' };
expect(isUniqueConstraintError(error)).toBe(false);
});
});

describe('non-matching errors', () => {
it('returns false for a generic Error', () => {
expect(isUniqueConstraintError(new Error('Something went wrong'))).toBe(false);
});

it('returns false for null', () => {
expect(isUniqueConstraintError(null)).toBe(false);
});

it('returns false for undefined', () => {
expect(isUniqueConstraintError(undefined)).toBe(false);
});

it('returns false for a plain string', () => {
expect(isUniqueConstraintError('duplicate key error')).toBe(false);
});

it('returns false for an error with no cause and no matching message', () => {
expect(isUniqueConstraintError({ message: 'Connection timeout' })).toBe(false);
});
});
});

describe('throwIfUniqueConstraintError', () => {
it('throws for a PostgreSQL 23505 error', () => {
const error = { cause: { code: '23505' }, message: 'Failed query: INSERT ...' };
expect(() => throwIfUniqueConstraintError(error, 'Resource already exists')).toThrow();
});

it('throws for a Doltgres 1062 error', () => {
const error = {
cause: { code: 'XX000', message: "1062: Duplicate entry 'x' for key 'PRIMARY'" },
message: 'Failed query: INSERT ...',
};
expect(() => throwIfUniqueConstraintError(error, 'Resource already exists')).toThrow();
});

it('throws with conflict code and provided message', async () => {
const error = { cause: { code: '23505' }, message: 'Failed query: INSERT ...' };
let caught: any;
try {
throwIfUniqueConstraintError(error, "Agent with ID 'x' already exists");
} catch (e) {
caught = e;
}
const body = JSON.parse(await caught.getResponse().text());
expect(body.error.code).toBe('conflict');
expect(body.error.message).toBe("Agent with ID 'x' already exists");
});

it('does not throw for a non-unique-constraint error', () => {
expect(() =>
throwIfUniqueConstraintError(new Error('Connection timeout'), 'Resource already exists')
).not.toThrow();
});

it('does not throw for null', () => {
expect(() => throwIfUniqueConstraintError(null, 'Resource already exists')).not.toThrow();
});
});
18 changes: 18 additions & 0 deletions packages/agents-core/src/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,24 @@ export const errorSchemaFactory = (code: ErrorCodes, description: string) => ({
},
});

export function isUniqueConstraintError(error: unknown): boolean {
const err = error as
| { cause?: { code?: string; message?: string }; message?: string }
| null
| undefined;
return (
err?.cause?.code === '23505' || // standard PostgreSQL unique violation
!!err?.cause?.message?.includes('1062') || // Doltgres wraps MySQL errno 1062 (duplicate entry)
!!err?.message?.includes('already exists') // generic fallback
);
}

export function throwIfUniqueConstraintError(error: unknown, message: string): void {
if (isUniqueConstraintError(error)) {
throw createApiError({ code: 'conflict', message });
}
}

export const commonCreateErrorResponses = {
400: errorSchemaFactory('bad_request', 'Bad Request'),
401: errorSchemaFactory('unauthorized', 'Unauthorized'),
Expand Down
10 changes: 2 additions & 8 deletions packages/agents-work-apps/src/slack/routes/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import * as crypto from 'node:crypto';
import { OpenAPIHono, z } from '@hono/zod-openapi';
import { createWorkAppSlackWorkspace } from '@inkeep/agents-core';
import { createWorkAppSlackWorkspace, isUniqueConstraintError } from '@inkeep/agents-core';
import { createProtectedRoute, noAuth } from '@inkeep/agents-core/middleware';
import runDbClient from '../../db/runDbClient';
import { env } from '../../env';
Expand Down Expand Up @@ -330,12 +330,7 @@ app.openapi(
'Persisted workspace installation to database'
);
} catch (dbError) {
const dbErrorMessage = dbError instanceof Error ? dbError.message : String(dbError);
const isDuplicate =
dbErrorMessage.includes('duplicate key') ||
dbErrorMessage.includes('unique constraint');

if (isDuplicate) {
if (isUniqueConstraintError(dbError)) {
logger.info(
{ teamId: workspaceData.teamId, tenantId },
'Workspace already exists in database'
Expand All @@ -349,7 +344,6 @@ app.openapi(
logger.error(
{
err: dbError,
dbErrorMessage,
pgCode,
teamId: workspaceData.teamId,
tenantId,
Expand Down
13 changes: 2 additions & 11 deletions packages/agents-work-apps/src/slack/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
deleteWorkAppSlackUserMapping,
findWorkAppSlackUserMapping,
findWorkAppSlackUserMappingByInkeepUserId,
isUniqueConstraintError,
verifySlackLinkToken,
} from '@inkeep/agents-core';
import { createProtectedRoute, inheritedWorkAppsAuth } from '@inkeep/agents-core/middleware';
Expand Down Expand Up @@ -258,17 +259,7 @@ app.openapi(
tenantId,
});
} catch (error) {
const isUniqueViolation =
(error instanceof Error &&
(error.message.includes('duplicate key') ||
error.message.includes('unique constraint'))) ||
(typeof error === 'object' &&
error !== null &&
'cause' in error &&
typeof (error as any).cause === 'object' &&
(error as any).cause?.code === '23505');

if (isUniqueViolation) {
if (isUniqueConstraintError(error)) {
logger.info({ userId: body.userId }, 'Concurrent link resolved — mapping already exists');
return c.json({ success: true });
}
Expand Down
Loading