Skill 61 · AWS Marketplace Metering
Subchapter 61.2
references/client-side-guardrails.mdMarkdown10 KBView on GitHub
BatchMeterUsage)This is the authoritative list of everything the metering pipeline checks or enforces client-side, before a record reaches the service call. The goal is that a record only reaches the service if it will be accepted — a bad record is caught locally, recorded with a specific reason, and never sent (so it neither wastes an API call nor risks poisoning a batch).
BatchMeterUsageMost checks live in the aggregator and are reject-and-finalize: the group’s rows get
a terminal meteringStatus = RejectedClientSide plus a specific reason, meteringPending
is cleared, and one UsageRecordRejected metric (dimensioned by Reason + ProductCode)
is emitted. Because a rejected row was never sent, its dedup grain
(CustomerAWSAccountId + LicenseArn + dimension + hour) is untouched — fix the
source row, re-write it within 24h, and it meters normally.
The per-reason “what to fix” table lives in
troubleshooting.md(“Client-side rejection reason codes”). This document is the COMPLETE guardrail inventory including the timing, batch-shaping, and idempotency guardrails that are not reason codes.
Every raw usage row that the pipeline finishes with reaches exactly one terminal
meteringStatus, so its fate is always observable on the row itself:
meteringStatus | Set by | meteringStatusReason | Meaning |
|---|---|---|---|
Aggregated | cleanup Lambda | Aggregated total quantity <sum> from <count> raw usage records | Row was folded into its group’s aggregated record (the happy path). |
RejectedClientSide | aggregator | the specific reason code (see below) | Row’s group failed a client-side guardrail and was never sent. |
AggregationExpired | discoverer (age-out) | — | Row aged past the 24h window BEFORE it could be aggregated (lost before aggregation). |
This is the RAW table’s domain. The
awsmp-aggregated-usagetable has its OWN, disjoint status domain for the SUBMISSION stage —Success/DuplicateRecord/CustomerNotSubscribed(the per-recordBatchMeterUsageResultsstatuses) /RejectedClientSide(a request-levelBatchMeterUsageexception isolated to the record — reason is the exception name, e.g.InvalidUsageDimensionExceptionorInvalidLicenseException; NOT a per-record status) /SubmissionExpired(aggregated but not submitted before the window/grace closed). “Expired” is never used un-qualified: raw =AggregationExpired, aggregated =SubmissionExpired.
The SUBMISSION outcome (meteringRecordId, and the Success/CustomerNotSubscribed/…
BatchMeterUsage status) is NOT written to the raw row — it lives on the
aggregated_usage record. A row still carrying meteringPending (no meteringStatus) is
simply awaiting processing.
| Guardrail | Behavior |
|---|---|
| Never meter the in-progress hour | Only completed hour buckets now-23h … now-1h are processed, oldest-first. A record is never built for an hour that is still accumulating. |
| Metering lock period | An hour is not aggregated/submitted until it has been closed for MeteringLockHours hours (default 1, configurable 1–20). Late usage has that long to land before the hour is submitted. |
| Month-boundary reconciliation | Previous-month hours keep the configured lock until 03:00 UTC on the 1st, then submit immediately (no lock) until the 06:00 UTC month-end cutoff, and are not aged out before then. A previous-month record written after 03:00 UTC on the 1st must already be pre-aggregated by the seller (else DuplicateRecord). |
TimestampInFuture | A group whose hour is in the future is rejected client-side. |
TimestampOutOfWindow | A group whose hour is older than the 24h acceptance window is rejected client-side — the service would otherwise reject the ENTIRE batch, so one stale record can’t poison others. |
MalformedTimestamp | A raw-usage row whose sort-key suffix is not an exact second-precision YYYY-MM-DDTHH:MM:SS timestamp (e.g. millisecond/fractional or hour-truncated) is rejected — enforces the second-precision writer contract at aggregation. |
MalformedSortKey | A raw-usage row whose sort key is missing or has fewer than the three account#dimension#timestamp segments is rejected. |
SortKeyMismatch | A raw-usage row whose sort-key account/dimension segments do not match the row’s own attributes (a corrupt/mis-keyed row) is rejected. |
| Age-out before submit | Raw rows past 24h that were never aggregated are terminally expired (meteringStatus = AggregationExpired, UsageAggregationExpired metric) and never enter a batch; an aggregated record that was never submitted in time is expired on the aggregated table (SubmissionExpired, UsageSubmissionExpired metric). |
| Guardrail (reason code) | Behavior |
|---|---|
NonNumericQuantity | A quantity not parseable as a number is rejected. |
NonIntegerQuantity | A fractional/decimal quantity (e.g. 2.5) is rejected — NEVER silently truncated or floored. |
NegativeQuantity | A negative quantity is rejected. |
| (booleans) | A boolean quantity is rejected (not treated as 0/1). |
| Zero handling | 0 is NOT a rejection. An in-window zero group is left pending (submitting 0 early would lock the hour at 0 via first-write-wins dedup); it is submitted as Quantity: 0 only at the oldest processed edge. |
| Guardrail (reason code) | Behavior |
|---|---|
MissingDimension | A group with no dimension is rejected (structural: a dimension key must be present). |
Dimension NAME validity is not checked client-side.
BatchMeterUsageis the authority — an undefined pricing dimension fails the call server-side with a request-levelInvalidUsageDimensionExceptionat submit time (NOT a per-record status; the submitter isolates the offending record toRejectedClientSidewith the exception name as reason, surfaced via theBatchMeterUsageExceptionmetric/alarm). This keeps the pipeline decoupled from the catalog, so adding or renaming a pricing dimension needs no redeploy.
| Guardrail (reason code) | Behavior |
|---|---|
InvalidLicenseArn | The LicenseArn is shape-validated against a License Manager ARN pattern BEFORE batching, so a malformed ARN never enters a batch (a bad ARN is a request-level error that would sink co-batched records). |
MissingCustomerAWSAccountId | A Concurrent-Agreements (ARN-keyed) group with no account ID is rejected. |
MissingLegacyIdentifier | A legacy (non-ARN) group with neither CustomerIdentifier nor CustomerAWSAccountId is rejected. |
| Guardrail (reason code) | Behavior |
|---|---|
MalformedAllocations | usageAllocations is not a list, or an entry is not an object. |
NegativeOrNonIntegerAllocation | An allocation quantity is negative or non-integer (same strict-int rule as quantity). |
MissingTagKeyOrValue | An allocation has no tags, or a tag is missing a non-empty Key/Value. |
TooManyTags | An allocation has more than 5 tags (service per-allocation cap). |
TooManyAllocations | The merged group produced more than 2500 distinct tag-set allocations (service per-record cap). |
MixedAllocatedAndUnallocated | Within one group, some non-zero rows carry allocations and some don’t — must be all-tagged or all-untagged. |
AllocationSumMismatch | The merged allocation quantities do not sum to the record Quantity. |
| tags carried through | The merge only SUMS the seller’s own tag sets; the pipeline never invents or re-partitions the split. |
| Guardrail | Behavior |
|---|---|
| ≤25 records per call | Records are chunked to the BatchMeterUsage hard limit. |
| ≤1 MB request payload | Independent of the record count: even a ≤25-record batch is rejected for SIZE if the serialized request exceeds 1 MB (large/many usageAllocations tag-sets, long ARNs). Chunk into fewer records per call so each request stays under 1 MB — it is the byte size, not the count. |
| CA vs legacy split | Concurrent-Agreements records (LicenseArn) and legacy records (ProductCode) go in separate batches — the two paths pass identifiers differently. |
| Request-level poison isolation | If a batch is rejected with a non-transient request-level error, the batch is recursively bisected (bounded to ⌈log2(batch size)⌉ = 5 levels, so a single poison record can always be isolated from a full 25-record batch) so a single poison record is isolated and terminally recorded while the rest still meter; at the depth cap a still-failing multi-record sub-batch is left pending for the next run (a BisectDepthExceeded metric fires) so a many-failure batch can’t overload the serial submitter. |
| Retry-once on unprocessed | Transient UnprocessedRecords are retried once; any still-unprocessed are left pending (re-metered next run) and the invocation fails so the error alarm fires. |
| Transient vs terminal | Only throttling / internal-service / service-unavailable errors are retried; every other error is terminal for that record. |
| Guardrail | Behavior |
|---|---|
| Conditional write commit point | A group is written to the aggregated-usage table at most once (conditional put); a duplicate visit no-ops, so re-discovery can’t submit the same group twice. |
| Second-precision dedup grain | The sort key bounds a group to one hour and de-duplicates the discovery tuples. |
| First-write-wins awareness | The pipeline deliberately avoids submitting 0 early because the service dedups per (CustomerAWSAccountId + LicenseArn + dimension + hour) and a premature 0 would lock the hour. |
Every guardrail above means a defective record is caught, reason-coded, and left
re-writable within the 24h window without ever calling the service — avoiding wasted
API calls, avoiding a single bad record rejecting a whole batch, and avoiding both
double-billing and silent under-billing. Anything that reaches BatchMeterUsage has
already passed all of these.