Skip to content

Errors

An error response is part of the contract. Callers branch on error codes, so a code whose meaning changes breaks working software as surely as a removed endpoint does.

Read this page before implementing any service, and before writing any client error handling.

  • None

Shelf Cloud services use one of two error envelopes, decided by the protocol the service speaks. They are not interchangeable, and a client written for one cannot parse the other.

EC2 query envelope — used by services mirroring Amazon EC2:

<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Errors>
<Error>
<Code>InvalidInstanceID.NotFound</Code>
<Message>The instance ID 'i-0a1b2c3d4e5f60718' does not exist</Message>
</Error>
</Errors>
<RequestID>b1e2c3d4-5678-90ab-cdef-1234567890ab</RequestID>
</Response>

No <Type> element. The request id element is RequestID.

Standard query envelope — used by services mirroring IAM and STS:

<ErrorResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<Error>
<Type>Sender</Type>
<Code>NoSuchEntity</Code>
<Message>The user with name deploy cannot be found</Message>
</Error>
<RequestId>b1e2c3d4-5678-90ab-cdef-1234567890ab</RequestId>
</ErrorResponse>

Has <Type>. The request id element is RequestId, with different casing from the EC2 form.

JSON envelope — used by services with no AWS counterpart, such as Billing and Account:

{
"__type": "QuoteExpired",
"message": "Quote quo-0a1b2c3d4e5f60718 expired at 2026-09-03T14:00:00Z"
}

with x-amzn-ErrorType also carrying the code as a response header.

ServiceEnvelope
EC2EC2 query
IAMStandard query
AccountJSON
BillingJSON

The casing differences between RequestID and RequestId are inherited and are preserved exactly. A client that parses one and not the other must behave here as it does against AWS.

Every response, successful or not, carries the request id in the x-amzn-RequestId header. This is the header every AWS SDK reads to populate its response metadata; a custom header would be invisible to all of them, and the customer told to “quote the request id” would have none to quote.

Successful EC2-protocol responses additionally carry a <requestId> element in the body, lowercase initial, as Amazon EC2 does.

StatusMeaning
400Malformed, invalid, expired, or a resource that does not exist
403Credentials unknown, or policy denies the action
404JSON-protocol services only, for a missing entity
409The request conflicts with the state of the resource
500An error on our side
503Temporarily unavailable, or the request rate was exceeded

There is no 401 anywhere in this platform. AWS uses none in this protocol, and two things depend on that:

  • The AWS SDKs correct their own clocks automatically when they see a 403 or 400 carrying a recognised skew-related code together with our Date header. A 401 defeats that recovery entirely.
  • RFC 9110 requires a 401 response to carry a WWW-Authenticate header. We send none, so a 401 would be a protocol violation, and intermediaries that special-case 401 would behave unpredictably.

Returned by every service.

CodeStatusMeaningRetry
IncompleteSignature400The Authorization header is malformedNo
InvalidAction400Unknown action, or an unimplemented versionNo
InvalidParameterValue400A parameter has an unacceptable valueNo
InvalidParameterCombination400Parameters cannot be used togetherNo
MissingParameter400A required parameter is absentNo
InvalidPaginationToken400The token is invalid or expiredNo
IdempotentParameterMismatch400Token reused with different parametersNo
RequestExpired400The timestamp is outside the permitted skewAfter fixing the clock
AuthFailure403The credentials could not be validatedNo
SignatureDoesNotMatch403The signature does not match the requestNo
InvalidClientTokenId403The access key id is unknown or inactiveNo
MissingAuthenticationToken403The request was not signedNo
UnauthorizedOperation403Policy denies this action on this resourceNo
AccessDenied403Policy denies this actionNo
InternalError500An unexpected failure on our sideYes
ServiceUnavailable503Temporarily unavailableYes

Note the statuses carefully. InvalidClientTokenId is 403, not 401. RequestExpired is 400, not 401 and not 403. IncompleteSignature is 400 while SignatureDoesNotMatch is 403. These are AWS’s own values, and matching them is what makes existing client recovery logic work.

Service kindCodeStatus
EC2-mirroringRequestLimitExceeded503
IAM-mirroringThrottling400
JSON servicesThrottling429

This is not an inconsistency we chose. Amazon EC2 returns RequestLimitExceeded, and the Terraform AWS provider matches that exact string for its backoff; a 429 there would produce no automatic retry at all for a large installed base of clients. Services with no AWS counterpart are free to use the modern 429 and do.

Each service’s error page states which applies to it.

A caller must never learn that a resource exists by the shape of the error.

The rule is a decision procedure, not a preference:

  1. Another account’s resource, or no such resource → the service’s not-found code. Identical responses in both cases.
  2. This account, resource exists, policy denies → UnauthorizedOperation or AccessDenied.

So UnauthorizedOperation confirms existence — and it may only be returned within the caller’s own account, where existence is not a secret from them.

No stack traces. No internal hostnames. No database or query text. No detail about another account. No hint that distinguishes a wrong password from an unknown user, or a wrong signature from a wrong key.

SignatureDoesNotMatch is deliberately identical whether the secret was wrong, the region was wrong, or the clock was wrong. An error that distinguished them would help an attacker as much as a customer, and the troubleshooting guides carry the differential diagnosis instead.

The Code is stable and is what callers branch on. The Message is written for a human, may change wording between releases, and must never be parsed.

A code is never given a new meaning. A behaviour needing a different meaning gets a new code, and the old code keeps returning what it always did until it is removed with notice.