Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
39 changes: 39 additions & 0 deletions apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";

const ParamsSchema = z.object({
key: z.string(),
});

const BodySchema = z.object({
taskIdentifier: z.string().min(1, "Task identifier is required"),
});

export const { action } = createActionApiRoute(
{
params: ParamsSchema,
body: BodySchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "write",
resource: () => ({}),
superScopes: ["write:runs", "admin"],
},
},
async ({ params, body, authentication }) => {
const service = new ResetIdempotencyKeyService();

try {
const result = await service.call(params.key, body.taskIdentifier, authentication.environment);
return json(result, { status: 200 });
} catch (error) {
if (error instanceof Error) {
return json({ error: error.message }, { status: 404 });
}
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
);
Comment on lines +1 to +39
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Tighten error handling: don’t turn all errors into 404s or leak messages

The happy path is good, but the current try/catch:

} catch (error) {
  if (error instanceof Error) {
    return json({ error: error.message }, { status: 404 });
  }
  return json({ error: "Internal Server Error" }, { status: 500 });
}

has a few issues:

  • Any runtime error (including unexpected server/DB failures) is surfaced as a 404, which is misleading.
  • Raw error.message is returned to clients, potentially leaking internal details.
  • It ignores the status already carried by ServiceValidationError.

A safer pattern is to only treat ServiceValidationError as a 4xx and default everything else to 500 with a generic message. For example:

-import { json } from "@remix-run/server-runtime";
+import { json } from "@remix-run/server-runtime";
+import { ServiceValidationError } from "~/v3/services/baseService.server";
@@
-  async ({ params, body, authentication }) => {
+  async ({ params, body, authentication }) => {
@@
-    try {
-      const result = await service.call(params.key, body.taskIdentifier, authentication.environment);
-      return json(result, { status: 200 });
-    } catch (error) {
-      if (error instanceof Error) {
-        return json({ error: error.message }, { status: 404 });
-      }
-      return json({ error: "Internal Server Error" }, { status: 500 });
-    }
+    try {
+      const result = await service.call(
+        params.key,
+        body.taskIdentifier,
+        authentication.environment
+      );
+      return json(result, { status: 200 });
+    } catch (error) {
+      if (error instanceof ServiceValidationError) {
+        return json({ error: error.message }, { status: error.status ?? 400 });
+      }
+
+      // Optionally log `error` here
+      return json({ error: "Internal Server Error" }, { status: 500 });
+    }
   }
 );

(or, if createActionApiRoute already handles ServiceValidationError globally, you can simply drop the try/catch and let it bubble).

This keeps client semantics accurate and avoids over‑exposing internal error messages.

🤖 Prompt for AI Agents
In apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts lines 1-39, the
catch block currently maps all Errors to a 404 and returns raw error.message;
update error handling to only treat ServiceValidationError as a client error
(use its status and safe message), and map every other error to a 500 with a
generic "Internal Server Error" payload (do not return raw error.message);
alternatively, if createActionApiRoute already handles ServiceValidationError
globally, remove the try/catch entirely and let errors bubble to the global
handler.

Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { parse } from "@conform-to/zod";
import { type ActionFunction, json } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
import { v3RunParamsSchema } from "~/utils/pathBuilder";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { environment } from "effect/Differ";

export const resetIdempotencyKeySchema = z.object({
taskIdentifier: z.string().min(1, "Task identifier is required"),
});

export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, runParam } =
v3RunParamsSchema.parse(params);

const formData = await request.formData();
const submission = parse(formData, { schema: resetIdempotencyKeySchema });

if (!submission.value) {
return json(submission);
}

try {
const { taskIdentifier } = submission.value;

const taskRun = await prisma.taskRun.findFirst({
where: {
friendlyId: runParam,
project: {
slug: projectParam,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
runtimeEnvironment: {
slug: envParam,
},
},
select: {
id: true,
idempotencyKey: true,
taskIdentifier: true,
runtimeEnvironmentId: true,
},
});

if (!taskRun) {
submission.error = { runParam: ["Run not found"] };
return json(submission);
}

if (!taskRun.idempotencyKey) {
return jsonWithErrorMessage(
submission,
request,
"This run does not have an idempotency key"
);
}

if (taskRun.taskIdentifier !== taskIdentifier) {
submission.error = { taskIdentifier: ["Task identifier does not match this run"] };
return json(submission);
}

const environment = await prisma.runtimeEnvironment.findUnique({
where: {
id: taskRun.runtimeEnvironmentId,
},
include: {
project: {
include: {
organization: true,
},
},
},
});

if (!environment) {
return jsonWithErrorMessage(
submission,
request,
"Environment not found"
);
}

const service = new ResetIdempotencyKeyService();

await service.call(taskRun.idempotencyKey, taskIdentifier, {
...environment,
organizationId: environment.project.organizationId,
organization: environment.project.organization,
});

return jsonWithSuccessMessage(
{ success: true },
request,
"Idempotency key reset successfully"
);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to reset idempotency key", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
return jsonWithErrorMessage(
submission,
request,
`Failed to reset idempotency key: ${error.message}`
);
} else {
logger.error("Failed to reset idempotency key", { error });
return jsonWithErrorMessage(
submission,
request,
`Failed to reset idempotency key: ${JSON.stringify(error)}`
);
}
}
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ArrowPathIcon,
CheckIcon,
CloudArrowDownIcon,
EnvelopeIcon,
Expand Down Expand Up @@ -29,6 +30,7 @@ import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { Spinner } from "~/components/primitives/Spinner";
import { toast } from "sonner";
import {
Table,
TableBody,
Expand All @@ -40,6 +42,7 @@ import {
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
import { ToastUI } from "~/components/primitives/Toast";
import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/RunTimeline";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import { RunIcon } from "~/components/runs/v3/RunIcon";
Expand Down Expand Up @@ -69,6 +72,7 @@ import {
v3BatchPath,
v3DeploymentVersionPath,
v3RunDownloadLogsPath,
v3RunIdempotencyKeyResetPath,
v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
Expand All @@ -81,6 +85,7 @@ import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.proje
import { requireUserId } from "~/services/session.server";
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
import { RealtimeStreamViewer } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
import { action as resetIdempotencyKeyAction } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset";

export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
Expand Down Expand Up @@ -293,6 +298,28 @@ function RunBody({
const isAdmin = useHasAdminAccess();
const { value, replace } = useSearchParams();
const tab = value("tab");
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();

// Handle toast messages from the reset action
useEffect(() => {
if (resetFetcher.data && resetFetcher.state === "idle") {
// Check if the response indicates success
if (resetFetcher.data && typeof resetFetcher.data === "object" && "success" in resetFetcher.data && resetFetcher.data.success === true) {
toast.custom(
(t) => (
<ToastUI
variant="success"
message="Idempotency key reset successfully"
t={t as string}
/>
),
{
duration: 5000,
}
);
}
}
}, [resetFetcher.data, resetFetcher.state]);

return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
Expand Down Expand Up @@ -543,17 +570,37 @@ function RunBody({
<Property.Item>
<Property.Label>Idempotency</Property.Label>
<Property.Value>
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
{run.idempotencyKey && (
<div>
Expires:{" "}
{run.idempotencyKeyExpiresAt ? (
<DateTime date={run.idempotencyKeyExpiresAt} />
) : (
"–"
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
{run.idempotencyKey && (
<div>
Expires:{" "}
{run.idempotencyKeyExpiresAt ? (
<DateTime date={run.idempotencyKeyExpiresAt} />
) : (
"–"
)}
</div>
)}
</div>
)}
{run.idempotencyKey && (
<resetFetcher.Form
method="post"
action={v3RunIdempotencyKeyResetPath(organization, project, environment, { friendlyId: runParam })}
>
<input type="hidden" name="taskIdentifier" value={run.taskIdentifier} />
<Button
type="submit"
variant="minimal/small"
LeadingIcon={ArrowPathIcon}
disabled={resetFetcher.state === "submitting"}
>
{resetFetcher.state === "submitting" ? "Resetting..." : "Reset"}
</Button>
</resetFetcher.Form>
)}
</div>
</Property.Value>
</Property.Item>
<Property.Item>
Expand Down
11 changes: 11 additions & 0 deletions apps/webapp/app/utils/pathBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,17 @@ export function v3RunStreamingPath(
return `${v3RunPath(organization, project, environment, run)}/stream`;
}

export function v3RunIdempotencyKeyResetPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath
) {
return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam(
project
)}/env/${environmentParam(environment)}/runs/${run.friendlyId}/idempotencyKey/reset`;
}

export function v3SchedulesPath(
organization: OrgForPath,
project: ProjectForPath,
Expand Down
44 changes: 44 additions & 0 deletions apps/webapp/app/v3/services/resetIdempotencyKey.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BaseService, ServiceValidationError } from "./baseService.server";

export class ResetIdempotencyKeyService extends BaseService {
public async call(
idempotencyKey: string,
taskIdentifier: string,
authenticatedEnv: AuthenticatedEnvironment
): Promise<{ id: string }> {
// Find all runs with this idempotency key and task identifier in the authenticated environment
const runs = await this._prisma.taskRun.findMany({
where: {
idempotencyKey,
taskIdentifier,
runtimeEnvironmentId: authenticatedEnv.id,
},
select: {
id: true,
},
});

if (runs.length === 0) {
throw new ServiceValidationError(
`No runs found with idempotency key: ${idempotencyKey} and task: ${taskIdentifier}`,
404
);
}

// Update all runs to clear the idempotency key
await this._prisma.taskRun.updateMany({
where: {
idempotencyKey,
taskIdentifier,
runtimeEnvironmentId: authenticatedEnv.id,
},
data: {
idempotencyKey: null,
idempotencyKeyExpiresAt: null,
},
});

return { id: idempotencyKey };
}
}
23 changes: 23 additions & 0 deletions docs/idempotency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,29 @@ function hash(payload: any): string {
}
```

## Resetting idempotency keys

You can reset an idempotency key to clear it from all associated runs. This is useful if you need to allow a task to be triggered again with the same idempotency key.

When you reset an idempotency key, it will be cleared for all runs that match both the task identifier and the idempotency key in the current environment. This allows you to trigger the task again with the same key.

```ts
import { idempotencyKeys } from "@trigger.dev/sdk";

// Reset an idempotency key for a specific task
await idempotencyKeys.reset("my-task", "my-idempotency-key");
```

The `reset` function requires both parameters:
- `taskIdentifier`: The identifier of the task (e.g., `"my-task"`)
- `idempotencyKey`: The idempotency key to reset

After resetting, any subsequent triggers with the same idempotency key will create new task runs instead of returning the existing ones.

<Note>
Resetting an idempotency key only affects runs in the current environment. The reset is scoped to the specific task identifier and idempotency key combination.
</Note>

## Important notes

Idempotency keys, even the ones scoped globally, are actually scoped to the task and the environment. This means that you cannot collide with keys from other environments (e.g. dev will never collide with prod), or to other projects and orgs.
Expand Down
Loading