Skip to content

Conversation

@constantly-dev
Copy link

Closes #508

Description

This PR introduces a new hook, beforeTimeout, which allows users to intercept and handle TimeoutErrors globally.

Currently, the beforeError hook only receives HTTPError, forcing users to wrap every request in a try-catch block to handle timeouts (e.g., for logging or UI notifications). This PR solves that inconvenience.

Implementation Details

Based on the discussion in #508, I have implemented the following:

  • Naming: Named the hook beforeTimeout as suggested by @sindresorhus.
  • Behavior: The hook receives the TimeoutError, allowing modification (e.g., changing the error message).
  • Control Flow: Crucially, the error is re-thrown after the hooks are executed. It does not silence the error, preserving Ky's promise rejection behavior.
  • Types: Added BeforeTimeoutHook type definition and updated Hooks interface.
  • Tests: Added comprehensive tests to ensure the hook is triggered and can modify the error object.

Example Usage

import ky from 'ky';

await ky('[https://example.com](https://example.com)', {
	timeout: 100,
	hooks: {
		beforeTimeout: [
			error => {
				// Log the timeout globally
				console.error('Request timed out:', error);

				// Or modify the error before it propagates
				error.message = 'Custom timeout message';

				return error;
			}
		]
	}
});

Comment on lines +54 to +57
for (const hook of ky.#options.hooks.beforeTimeout) {
// eslint-disable-next-line no-await-in-loop
timeoutError = await hook(timeoutError);
}
Copy link

Choose a reason for hiding this comment

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

quick observation. This suggests that timeoutError is continuously overwritten by each subsequent hook in beforeTimeout, and as a result, only the error returned by the last beforeTimeout hook will be thrown.

Copy link
Author

@constantly-dev constantly-dev Dec 23, 2025

Choose a reason for hiding this comment

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

@samicey
Thank you for your response!
Yes, you are correct. It works as a pipeline where the output of one hook becomes the input of the next.

I intentionally implemented it this way to maintain consistency with ky's existing beforeError hook logic.
If you check the beforeError implementation in Ky.ts, it uses the exact same pattern:

// Existing beforeError logic in Ky.ts
for (const hook of ky.#options.hooks.beforeError) {
    error = await hook(error, {retryCount: ky.#retryCount});
}

I believe applying the same pattern to beforeTimeout is the most consistent approach for this library.

Do you think there is a better approach for this?

@sholladay
Copy link
Collaborator

I think I would mildly prefer to send TimeoutError through beforeError rather than a new beforeTimeout hook (or do both). It enables simpler handling of multiple error types.

timeoutError = await hook(timeoutError);
}

throw timeoutError;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This can throw undefined pretty easily, if the hook just doesn't return anything, which is bad. Looks like beforeError already has this problem, too.

I think we should check the hook result and overwrite error if the result is an error (of any kind)..

Copy link
Author

@constantly-dev constantly-dev Dec 24, 2025

Choose a reason for hiding this comment

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

Regarding the potential undefined issue, I plan to fix it by verifying the hook's return value before assigning it. Here is the implementation:

for (const hook of ky.#options.hooks.beforeTimeout) {
    // eslint-disable-next-line no-await-in-loop
    const result = await hook(timeoutError);
    
    // Only overwrite if the hook returns a valid Error instance
    if (result instanceof Error) {
        timeoutError = result;
    }
}

Does this look good to you?

@constantly-dev
Copy link
Author

I think I would mildly prefer to send TimeoutError through beforeError rather than a new beforeTimeout hook (or do both). It enables simpler handling of multiple error types.

@sholladay
In the original issue (#508), suggested naming it beforeTimeout explicitly. I am happy to merge this into beforeError if that is the preferred direction now.

However, beforeError is currently typed to strictly receive HTTPError. If we merge them, we'll need to broaden the type definition (e.g., HTTPError | TimeoutError).

Which direction should I proceed with? I'll update the PR as soon as you guys decide.

// Or modify the error message
error.message = 'Custom timeout message';

return error;
Copy link
Owner

Choose a reason for hiding this comment

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

use tab-indentation

```
import ky from 'ky';

await ky('[https://example.com](https://example.com)', {
Copy link
Owner

Choose a reason for hiding this comment

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

typo


export type BeforeErrorHook = (error: HTTPError, state: BeforeErrorState) => HTTPError | Promise<HTTPError>;

export type BeforeTimeoutHook = (error: TimeoutError) => TimeoutError | Promise<TimeoutError>;
Copy link
Owner

Choose a reason for hiding this comment

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

Must be exported from index.d.ts too.


for (const hook of ky.#options.hooks.beforeTimeout) {
// eslint-disable-next-line no-await-in-loop
timeoutError = await hook(timeoutError);
Copy link
Owner

Choose a reason for hiding this comment

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

Suggested change
timeoutError = await hook(timeoutError);
timeoutError = await hook({error: timeoutError});

should be an options-object in case we need to add more parameters in the future.

@sindresorhus
Copy link
Owner

I think it should be in beforeError, but it's a breaking change, so cannot happen yet. You can do the PR, but we cannot merge until we start work on v2, which is not going to happen yet.

@sholladay
Copy link
Collaborator

In the original issue (#508), suggested naming it beforeTimeout explicitly. I am happy to merge this into beforeError if that is the preferred direction now.

@constantly-dev if you look closely, you'll notice that I edited the issue title to specify beforeError after the initial discussion where Sindre and I both seemed a bit skeptical of the need for a separate hook. But you're right, I didn't add a note to the top of the thread. I tend to avoid editing people's posts unless it's really necessary. Sorry for the confusion.

Let's proceed with beforeError.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pass TimeoutErrors to beforeError hooks

4 participants