Skip to content
Draft
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
3 changes: 2 additions & 1 deletion packages/core/src/helpers/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export const NB_RETRIES = 5;

// Do a retriable fetch.
export const doRequest = async <T>(opts: RequestOpts): Promise<T> => {
const { auth, url, method = 'GET', getData, type = 'text' } = opts;
const { auth, url, method = 'GET', getData, type = 'text', signal } = opts;
const retryOpts: retry.Options = {
retries: opts.retries === 0 ? 0 : opts.retries || NB_RETRIES,
onRetry: opts.onRetry,
Expand All @@ -106,6 +106,7 @@ export const doRequest = async <T>(opts: RequestOpts): Promise<T> => {
// This is needed for sending body in NodeJS' Fetch.
// https://github.com/nodejs/node/issues/46221
duplex: 'half',
signal,
};
let requestHeaders: RequestInit['headers'] = {
'X-Datadog-Origin': 'build-plugins',
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export type RequestOpts = {
retries?: number;
minTimeout?: number;
maxTimeout?: number;
signal?: AbortSignal;
};

export type ResolvedEntry = { name?: string; resolved: string; original: string };
Expand Down
23 changes: 23 additions & 0 deletions packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ A plugin to upload assets to Datadog's storage
- [apps.enable](#appsenable)
- [apps.include](#appsinclude)
- [apps.authOverrides.method](#appsauthoverridesmethod)
- [apps.longPolling](#appslongpolling)
- [apps.identifier](#appsidentifier)
- [apps.name](#appsname)
- [apps.description](#appsdescription)
Expand Down Expand Up @@ -46,6 +47,12 @@ apps?: {
authOverrides?: {
method?: 'apiKey' | 'oauth';
};
longPolling?: {
maxRetries?: number;
jitter?: boolean;
exponentialBackoff?: boolean;
timeoutMs?: number;
};
publish?: boolean;
}
```
Expand Down Expand Up @@ -98,6 +105,22 @@ When the method is `oauth`, the plugin derives OAuth client settings from the re

For first-time authorization, the plugin starts a temporary local HTTP callback server, opens Datadog authorization in the browser, exchanges the authorization code with PKCE, and saves the returned token response for later uploads.

### apps.longPolling

> default: `{ maxRetries: 10, jitter: true, exponentialBackoff: true, timeoutMs: 40000 }`

Controls how the dev server's `/__dd/executeAction` endpoint polls Datadog's long-poll execution API while waiting for a backend function to finish running.

- `maxRetries`: maximum number of long-poll attempts before giving up. Set to `1` to disable long-polling retries entirely and only poll once.
- `jitter`: randomize the delay before each retry so that several backend functions polling at the same time don't all retry in lockstep.
- `exponentialBackoff`: grow the delay between retries exponentially instead of using a fixed delay.
- `timeoutMs`: deadline for a single long-poll attempt. An attempt that stalls past it is abandoned and retried against the same receipt, so a dropped connection is re-polled instead of hanging indefinitely.

The retry delay is capped at 2s: the server answering `done: false` is the expected outcome of a healthy poll rather than a failure, and any delay here is time with no poll in flight.

> [!NOTE]
> `timeoutMs` must stay comfortably above the server's ~30s long-poll window. Setting it at or below that window causes healthy polls to be aborted as they race their own response.

OAuth token and authorization URLs are derived from `auth.site`, so it must match your Datadog data center (e.g. `datadoghq.com`, `us5.datadoghq.com`, `datadoghq.eu`). If `auth.site` includes a custom subdomain (e.g. `myorg.us5.datadoghq.com`), the browser is sent to that subdomain for authorization, while the token exchange and upload requests still use the base site (`us5.datadoghq.com`).

### apps.identifier
Expand Down
6 changes: 6 additions & 0 deletions packages/plugins/apps/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,12 @@ describe('Apps Plugin - getPlugins', () => {
},
dryRun: false,
include: [],
longPolling: {
maxRetries: 10,
timeoutMs: 40000,
jitter: true,
exponentialBackoff: true,
},
},
});

Expand Down
18 changes: 18 additions & 0 deletions packages/plugins/apps/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ export type AuthMethod = 'apiKey' | 'oauth';

export type AppsProtectionLevel = 'direct_publish' | 'approval_required';

/** Controls how the dev server retries the Datadog long-poll execution endpoint. */
export type LongPollingOptions = {
/** Max long-poll attempts. `1` polls once and never retries. Default: `10`. */
maxRetries?: number;
/** Randomize retry delays so concurrent pollers don't sync up. Default: `true`. */
jitter?: boolean;
/** Grow the delay between retries exponentially. Default: `true`. */
exponentialBackoff?: boolean;
/**
* Deadline for one attempt, in ms. Must stay above the server's ~30s window,
* otherwise healthy polls get aborted. Default: `40000`.
*/
timeoutMs?: number;
};

export type AppsOptions = {
enable?: boolean;
include?: string[];
Expand Down Expand Up @@ -38,6 +53,8 @@ export type AppsOptions = {
authOverrides?: {
method?: AuthMethod;
};
/** Controls how the dev server retries the Datadog long-poll execution endpoint. */
longPolling?: LongPollingOptions;
};

export type AppsManifest = {
Expand Down Expand Up @@ -69,6 +86,7 @@ export type AppsOptionsWithDefaults = Omit<
authOverrides: {
method: AuthMethod;
};
longPolling: Required<LongPollingOptions>;
}
>,
'enable'
Expand Down
60 changes: 60 additions & 0 deletions packages/plugins/apps/src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ describe('Apps Plugin - validateOptions', () => {
include: [],
identifier: undefined,
name: undefined,
longPolling: {
maxRetries: 10,
timeoutMs: 40000,
jitter: true,
exponentialBackoff: true,
},
});
});

Expand Down Expand Up @@ -127,6 +133,12 @@ describe('Apps Plugin - validateOptions', () => {
include: ['public/**/*', 'dist/**/*'],
identifier: 'my-app',
name: undefined,
longPolling: {
maxRetries: 10,
timeoutMs: 40000,
jitter: true,
exponentialBackoff: true,
},
});
});

Expand Down Expand Up @@ -181,6 +193,54 @@ describe('Apps Plugin - validateOptions', () => {
expect(result.authOverrides.method).toBe('apiKey');
});
});

describe('longPolling', () => {
test('Should default maxRetries, jitter and exponentialBackoff', () => {
const result = validateOptions({ apps: {} });
expect(result.longPolling).toEqual({
maxRetries: 10,
timeoutMs: 40000,
jitter: true,
exponentialBackoff: true,
});
});

test('Should allow disabling retries by setting maxRetries to 1', () => {
const result = validateOptions({ apps: { longPolling: { maxRetries: 1 } } });
expect(result.longPolling.maxRetries).toBe(1);
});

test('Should allow disabling jitter and exponentialBackoff', () => {
const result = validateOptions({
apps: { longPolling: { jitter: false, exponentialBackoff: false } },
});
expect(result.longPolling.jitter).toBe(false);
expect(result.longPolling.exponentialBackoff).toBe(false);
});

test('Should allow overriding timeoutMs', () => {
const result = validateOptions({ apps: { longPolling: { timeoutMs: 60_000 } } });
expect(result.longPolling.timeoutMs).toBe(60_000);
});

test('Should throw when timeoutMs is not a positive number', () => {
expect(() => validateOptions({ apps: { longPolling: { timeoutMs: 0 } } })).toThrow(
'apps.longPolling.timeoutMs must be a positive number.',
);
expect(() => validateOptions({ apps: { longPolling: { timeoutMs: -1 } } })).toThrow(
'apps.longPolling.timeoutMs must be a positive number.',
);
});

test('Should throw when maxRetries is not a positive integer', () => {
expect(() => validateOptions({ apps: { longPolling: { maxRetries: 0 } } })).toThrow(
'apps.longPolling.maxRetries must be an integer >= 1.',
);
expect(() => validateOptions({ apps: { longPolling: { maxRetries: 1.5 } } })).toThrow(
'apps.longPolling.maxRetries must be an integer >= 1.',
);
});
});
});

describe('new app properties', () => {
Expand Down
23 changes: 23 additions & 0 deletions packages/plugins/apps/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,28 @@ const hasApiKeyAuth = (options: Options): boolean =>
(getDDEnvValue('APP_KEY') || options.auth?.appKey),
);

const resolveLongPolling = (
longPolling: AppsOptions['longPolling'],
): AppsOptionsWithDefaults['longPolling'] => {
const maxRetries = longPolling?.maxRetries ?? 10;
const timeoutMs = longPolling?.timeoutMs ?? 40_000;

if (!Number.isInteger(maxRetries) || maxRetries < 1) {
throw new Error('apps.longPolling.maxRetries must be an integer >= 1.');
}

if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error('apps.longPolling.timeoutMs must be a positive number.');
}

return {
maxRetries,
timeoutMs,
jitter: longPolling?.jitter ?? true,
exponentialBackoff: longPolling?.exponentialBackoff ?? true,
};
};

export const validateOptions = (options: Options): AppsOptionsWithDefaults => {
const resolvedOptions = (options[CONFIG_KEY] || {}) as AppsOptions;
const method =
Expand All @@ -50,5 +72,6 @@ export const validateOptions = (options: Options): AppsOptionsWithDefaults => {
authOverrides: {
method,
},
longPolling: resolveLongPolling(resolvedOptions.longPolling),
};
};
Loading
Loading