Skip to content

[BUG] Multi-unit duration strings are enforced with the wrong timeout #3894

@juanuicich

Description

@juanuicich

Describe the issue

The engine accepts 42m30s during workflow registration, stores that value, then enforces it as roughly 42s.

The mismatch is in convert_duration_to_interval(duration text). It extracts the leading integer and chooses the unit from the final suffix, so 42m30s becomes 42 seconds.

Environment

  • SDK: @hatchet-dev/typescript-sdk@1.17.2
  • Latest SDK checked: @hatchet-dev/typescript-sdk@1.22.1, published 2026-05-05
  • Engine: self-hosted ghcr.io/hatchet-dev/hatchet/hatchet-lite:latest
  • Local engine image digest: sha256:fd87ee914ad64845557b2f6130c749fbe99e132c568e1dac1ec2e49b0cbfaa96

Expected behavior

If registration accepts 42m30s, runtime enforcement should apply a timeout of 42 minutes and 30 seconds.

If multi-unit strings are not supported, registration should reject them instead of storing a value that runtime enforcement parses differently.

Code to Reproduce, Logs, or Screenshots

import { Hatchet } from "@hatchet-dev/typescript-sdk";

const hatchet = Hatchet.init();

const task = hatchet.task({
  name: "multi-unit-timeout-repro",
  executionTimeout: 2_550_000,
  fn: async () => {
    await new Promise((resolve) => setTimeout(resolve, 120_000));
    return { ok: true };
  },
});

2_550_000 ms serializes to 42m30s. The task is cancelled after roughly 43 seconds, even though the configured timeout is 42m30s.

Event log from the failed task run:

[
  {
    "eventType": "TIMED_OUT",
    "message": "Task exceeded timeout of 42m30s",
    "timestamp": "2026-04-30T09:31:59.157Z"
  },
  {
    "eventType": "STARTED",
    "timestamp": "2026-04-30T09:31:16.302Z"
  }
]

The event timestamps are 42.86 seconds apart, not 42m30s.

The runtime SQL helper is where the stored string is converted to an interval for enforcement. A direct query shows the enforced interval:

SELECT
  convert_duration_to_interval('11m') AS eleven_m,
  convert_duration_to_interval('42m30s') AS forty_two_m_thirty_s,
  convert_duration_to_interval('1h30m5s') AS one_h_thirty_m_five_s;
 eleven_m | forty_two_m_thirty_s | one_h_thirty_m_five_s
----------+----------------------+-----------------------
 00:11:00 | 00:00:42             | 00:00:01

Single-unit strings work as expected. For example, 660_000 ms serializes to 11m and is enforced as 11 minutes.

Re-verification (2026-05-12)

Pulled ghcr.io/hatchet-dev/hatchet/hatchet-lite:latest today, digest sha256:9511a4175d6d229ba5c14e8ed79f5ef4c458c86d2b2f7b3f8a6a0ed3f0350b7e. The image was built on 2026-04-30T15:21:28Z, so :latest has not been rebuilt since the original observation.

Running the same query against a postgres migrated by the image's bundled /hatchet-migrate returns the same result:

 eleven_m | forty_two_m_thirty_s | one_h_thirty_m_five_s
----------+----------------------+-----------------------
 00:11:00 | 00:00:42             | 00:00:01
(1 row)

Additional context

The TypeScript SDK accepts and emits multi-unit duration strings:

type TwoUnitDurations = `${number}h${number}m` | `${number}h${number}s` | `${number}m${number}s`;
type ThreeUnitDurations = `${number}h${number}m${number}s`;

Numeric durations are normalized to the same Go-style string format. For example, 2_550_000 ms becomes 42m30s.

Registration validation accepts these strings because the duration validator uses Go's time.ParseDuration, which accepts values such as 42m30s and 1h30m5s.

The public timeout docs describe a single-unit format, such as 10s, 4m, and 1h, but the TypeScript SDK type/tests and engine validation accept multi-unit values.

The current SQL helper does not enforce the same grammar:

num_value := substring(duration from '^\d+');

RETURN CASE
    WHEN duration LIKE '%ms' THEN make_interval(secs => num_value::float / 1000)
    WHEN duration LIKE '%s' THEN make_interval(secs => num_value)
    WHEN duration LIKE '%m' THEN make_interval(mins => num_value)
    WHEN duration LIKE '%h' THEN make_interval(hours => num_value)
    WHEN duration LIKE '%d' THEN make_interval(days => num_value)
    WHEN duration LIKE '%w' THEN make_interval(days => num_value * 7)
    WHEN duration LIKE '%y' THEN make_interval(months => num_value * 12)
    ELSE '5 minutes'::interval
END;

Because that helper is reused, affected surfaces include execution timeouts, schedule timeouts, ctx.refreshTimeout, durable sleeps, onFailure task timeouts, and onSuccess task timeouts.

An engine-side fix is needed because existing stored values and non-TypeScript clients can still send multi-unit strings. Runtime enforcement should match registration validation, or registration should reject values that runtime enforcement cannot parse correctly.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions