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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
56 changes: 56 additions & 0 deletions scripts/generate-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,16 @@ const generateServiceIndex = (
});
code += " },\n";
}
if (
metadata.retryableErrors &&
Object.keys(metadata.retryableErrors).length > 0
) {
code += " retryableErrors: {\n";
Object.entries(metadata.retryableErrors).forEach(([errorName, error]) => {
code += ` "${errorName}": ${JSON.stringify(error)},\n`;
});
code += " },\n";
}
code += "} as const satisfies ServiceMetadata;\n\n";

// // Re-export all types from types.ts for backward compatibility
Expand Down Expand Up @@ -1707,6 +1717,42 @@ const generateServiceTypes = (serviceName: string, manifest: Manifest) =>
// Extract operation HTTP mappings and trait mappings
let operationMappings: Record<string, any> = {};

const extractRetryableError = (
shapeId: string,
): [string, Record<string, string>] => {
const shape = manifest.shapes[shapeId];
if (!shape || shape.type !== "structure" || !shape.members)
return ["", {}];

if (shape.traits["smithy.api#retryable"] == null) return ["", {}];
// const isEmptyTrait = Object.keys(shape.members).length === 0;

// return shape;
const name = extractShapeName(shapeId);

return [
name,
{
retryAfterSeconds:
shape?.members?.retryAfterSeconds?.traits?.[
"smithy.api#httpHeader"
],
// members: Object.entries(shape.members).reduce(
// (acc, [key, value]) => {
// if (value?.traits?.["smithy.api#httpHeader"]) {
// acc[key] = value?.traits?.["smithy.api#httpHeader"];
// }
// return acc;
// },
// {} as Record<string, string>,
// ),
// ...(Object.keys(shape.traits["smithy.api#retryable"]).length > 0
// ? shape.traits["smithy.api#retryable"]
// : {}),
},
];
};

const extractHttpTraits = (shapeId: string): Record<string, string> => {
const shape = manifest.shapes[shapeId];
if (!shape || shape.type !== "structure" || !shape.members) return {};
Expand Down Expand Up @@ -1742,6 +1788,8 @@ const generateServiceTypes = (serviceName: string, manifest: Manifest) =>
) as Record<string, string>;
};

let retryableErrors = {};

if (protocol === "restJson1") {
for (const operation of operations) {
const httpTrait = operation.shape.traits?.["smithy.api#http"];
Expand All @@ -1753,6 +1801,13 @@ const generateServiceTypes = (serviceName: string, manifest: Manifest) =>
const outputTraits = operation.shape.output
? extractHttpTraits(operation.shape.output.target)
: {};
const errorList =
operation?.shape?.errors
?.map((e) => extractRetryableError(e.target))
.filter((error) => Object.keys(error[1]).length > 0) ?? [];
for (const error of errorList) {
retryableErrors[error[0]] = error[1];
}

if (Object.keys(outputTraits).length > 0) {
// Store both HTTP mapping and trait mappings
Expand Down Expand Up @@ -1839,6 +1894,7 @@ const generateServiceTypes = (serviceName: string, manifest: Manifest) =>
...(Object.keys(operationMappings).length > 0 && {
operations: operationMappings,
}),
retryableErrors,
};

return { code, metadata };
Expand Down
102 changes: 97 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import { Credentials, fromStaticCredentials } from "./credentials.ts";
import type { AwsErrorMeta } from "./error.ts";
import { DefaultFetch, Fetch } from "./fetch.service.ts";
import type { ProtocolHandler } from "./protocols/interface.ts";
import * as Schedule from "effect/Schedule";
import * as Duration from "effect/Duration";
import { pipe } from "effect";
import * as Context from "effect/Context";

export const retryableErrorTags = [
"InternalFailure",
"RequestExpired",
"ServiceException",
"ServiceUnavailable",
"ThrottlingException",
"TooManyRequestsException",
];

const errorTags: {
[serviceName: string]: {
Expand All @@ -17,13 +30,27 @@ const errorTags: {
function createServiceError(
serviceName: string,
errorName: string,
errorMeta: AwsErrorMeta & { message?: string },
errorMeta: AwsErrorMeta & {
message?: string;
retryable:
| {
retryAfterSeconds?: number;
}
| false;
},
) {
// Create a tagged error dynamically with the correct error name
return new ((errorTags[serviceName] ??= {})[errorName] ??= (() =>
Data.TaggedError(errorName)<AwsErrorMeta & { message?: string }>)())(
errorMeta,
);
Data.TaggedError(errorName)<
AwsErrorMeta & {
message?: string;
retryable:
| {
retryAfterSeconds?: number;
}
| false;
}
>)())(errorMeta);
}

// Types
Expand All @@ -46,6 +73,7 @@ export interface ServiceMetadata {
readonly outputTraits?: Record<string, string>;
}
>; // Operation mappings for restJson1 and trait mappings
retryableErrors?: Record<string, { retryAfterSeconds?: string }>;
}

export interface AwsCredentials {
Expand Down Expand Up @@ -94,6 +122,8 @@ export function createServiceProxy<T>(
onNone: () => DefaultFetch,
});

const retryPolicy = yield* Effect.serviceOption(RetryPolicy);

// Convert camelCase method to PascalCase operation
const operation =
methodName.charAt(0).toUpperCase() + methodName.slice(1);
Expand Down Expand Up @@ -213,6 +243,7 @@ export function createServiceProxy<T>(
response,
statusCode,
response.headers,
metadata,
),
);

Expand All @@ -229,14 +260,75 @@ export function createServiceProxy<T>(
{
...errorMeta,
message: parsedError.message,
retryable: parsedError.retryable ?? false,
},
),
);
}
});
return program;
return Effect.gen(function* () {
const retryPolicy = yield* Effect.serviceOption(RetryPolicy);
const randomNumber = Option.getOrUndefined(retryPolicy) ?? {
maxRetries: 5,
delay: Duration.millis(100),
};
return yield* withRetry(program, randomNumber);
});
};
},
},
) as T;
}

class RetryPolicy extends Context.Tag("RetryPolicy")<
RetryPolicy,
{
maxRetries: number;
delay: Duration.Duration;
}
>() {}

const withRetry = <A>(
operation: Effect.Effect<
A,
{
readonly _tag: string;
retryable:
| {
retryAfterSeconds?: number;
}
| false;
}
>,
randomNumber: {
maxRetries: number;
delay: Duration.Duration;
},
) =>
pipe(
operation,
Effect.retry({
while: (error) =>
error.retryable !== false || retryableErrorTags.includes(error._tag),
schedule: pipe(
Schedule.exponential(randomNumber.delay),
Schedule.passthrough,
Schedule.addDelay((err) => {
const error = err as {
readonly _tag: string;
retryable:
| {
retryAfterSeconds?: number;
}
| false;
};

return typeof error?.retryable === "object" &&
error?.retryable?.retryAfterSeconds != null
? Duration.seconds(error?.retryable?.retryAfterSeconds)
: Duration.zero;
}),
Schedule.compose(Schedule.recurs(randomNumber.maxRetries)),
),
}),
);
1 change: 1 addition & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Data from "effect/Data";
export interface AwsErrorMeta {
readonly statusCode: number;
readonly requestId?: string;
readonly retryAfterSeconds?: number;
}

// Common AWS errors that can occur across all services
Expand Down
9 changes: 8 additions & 1 deletion src/protocols/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export interface ParsedError {
readonly errorType: string;
readonly message: string;
readonly requestId?: string;
readonly retryable?:
| {
retryAfterSeconds?: number;
retryAttempts?: number;
}
| false;
}

export interface ProtocolRequest {
Expand Down Expand Up @@ -37,6 +43,7 @@ export interface ProtocolHandler {
parseError(
responseText: Response,
statusCode: number,
headers?: Headers,
headers: Headers,
serviceMetadata: ServiceMetadata,
): Promise<ParsedError>;
}
13 changes: 12 additions & 1 deletion src/protocols/rest-json-1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ export class RestJson1Handler implements ProtocolHandler {
async parseError(
response: Response,
_statusCode: number,
headers?: Headers,
headers: Headers,
metadata: ServiceMetadata,
): Promise<ParsedError> {
let errorData: any;
const responseText = await response.text();
Expand All @@ -154,11 +155,21 @@ export class RestJson1Handler implements ProtocolHandler {
headers?.get("x-amzn-requestid") ||
headers?.get("x-amz-request-id") ||
undefined;
const retryable =
metadata?.retryableErrors?.[errorType] != null
? {
retryAfterSeconds:
headers?.get(
metadata?.retryableErrors?.[errorTypes]?.retryAfterSeconds,
) ?? undefined,
}
: false;

return {
errorType,
message,
requestId,
retryable,
};
}

Expand Down
Loading