Skip to content
Merged
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
258 changes: 258 additions & 0 deletions crates/consts/src/error_class.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
//! Client-facing error classification: the Bedrock-derived closed set from
//! the gateway error contract. Every rendered error carries exactly one
//! class; the numeric `ErrCode` stays internal (logs/metrics only).

use crate::ErrCode;

/// One classification from the contract's closed set. `ModelStreamError` is
/// in-stream only and never renders at the HTTP phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrClass {
Validation,
/// Hard quota or balance exhaustion; retry only after quota becomes available.
ServiceQuotaExceeded,
UnrecognizedClient,
AccessDenied,
ResourceNotFound,
ModelTimeout,
Conflict,
RequestEntityTooLarge,
ModelError,
Throttling,
InternalServer,
ServiceUnavailable,
ModelStreamError,
}

impl ErrClass {
/// PascalCase exception name, the `x-amzn-errortype` header value.
pub const fn name(self) -> &'static str {
match self {
ErrClass::Validation => "ValidationException",
ErrClass::ServiceQuotaExceeded => "ServiceQuotaExceededException",
ErrClass::UnrecognizedClient => "UnrecognizedClientException",
ErrClass::AccessDenied => "AccessDeniedException",
ErrClass::ResourceNotFound => "ResourceNotFoundException",
ErrClass::ModelTimeout => "ModelTimeoutException",
ErrClass::Conflict => "ConflictException",
ErrClass::RequestEntityTooLarge => "RequestEntityTooLargeException",
ErrClass::ModelError => "ModelErrorException",
ErrClass::Throttling => "ThrottlingException",
ErrClass::InternalServer => "InternalServerException",
ErrClass::ServiceUnavailable => "ServiceUnavailableException",
ErrClass::ModelStreamError => "ModelStreamErrorException",
}
}

/// snake_case of [`name`](Self::name), the body `code` field.
pub const fn code(self) -> &'static str {
match self {
ErrClass::Validation => "validation_exception",
ErrClass::ServiceQuotaExceeded => "service_quota_exceeded_exception",
ErrClass::UnrecognizedClient => "unrecognized_client_exception",
ErrClass::AccessDenied => "access_denied_exception",
ErrClass::ResourceNotFound => "resource_not_found_exception",
ErrClass::ModelTimeout => "model_timeout_exception",
ErrClass::Conflict => "conflict_exception",
ErrClass::RequestEntityTooLarge => "request_entity_too_large_exception",
ErrClass::ModelError => "model_error_exception",
ErrClass::Throttling => "throttling_exception",
ErrClass::InternalServer => "internal_server_exception",
ErrClass::ServiceUnavailable => "service_unavailable_exception",
ErrClass::ModelStreamError => "model_stream_error_exception",
}
}

/// External HTTP status rendered to clients.
pub const fn status(self) -> u16 {
match self {
ErrClass::Validation | ErrClass::ServiceQuotaExceeded => 400,
ErrClass::UnrecognizedClient => 401,
ErrClass::AccessDenied => 403,
ErrClass::ResourceNotFound => 404,
ErrClass::ModelTimeout => 408,
ErrClass::Conflict => 409,
ErrClass::RequestEntityTooLarge => 413,
// ModelStreamError never renders at the HTTP phase; 424 nominal
ErrClass::ModelError | ErrClass::ModelStreamError => 424,
ErrClass::Throttling => 429,
ErrClass::InternalServer => 500,
ErrClass::ServiceUnavailable => 503,
}
}

/// Coarse `type` for the OpenAI envelope, for SDKs that key on it.
pub const fn openai_type(self) -> &'static str {
match self {
ErrClass::Validation
| ErrClass::ServiceQuotaExceeded
| ErrClass::Conflict
| ErrClass::RequestEntityTooLarge => "invalid_request_error",
ErrClass::UnrecognizedClient => "authentication_error",
ErrClass::AccessDenied => "permission_denied_error",
ErrClass::ResourceNotFound => "not_found_error",
ErrClass::ModelTimeout => "timeout_error",
ErrClass::ModelError | ErrClass::ModelStreamError => "model_error",
ErrClass::Throttling => "rate_limit_error",
ErrClass::InternalServer | ErrClass::ServiceUnavailable => "server_error",
}
}

/// `type` for the Anthropic envelope — the discriminator its SDKs
/// dispatch exceptions on.
pub const fn anthropic_type(self) -> &'static str {
match self {
ErrClass::Validation | ErrClass::ServiceQuotaExceeded | ErrClass::Conflict => {
"invalid_request_error"
}
ErrClass::UnrecognizedClient => "authentication_error",
ErrClass::AccessDenied => "permission_error",
ErrClass::ResourceNotFound => "not_found_error",
ErrClass::RequestEntityTooLarge => "request_too_large",
ErrClass::Throttling => "rate_limit_error",
ErrClass::ServiceUnavailable => "overloaded_error",
ErrClass::ModelTimeout
| ErrClass::ModelError
| ErrClass::ModelStreamError
| ErrClass::InternalServer => "api_error",
}
}

/// Classify an internal error for rendering. `None` for 499
/// (client-closed): the peer is gone, nothing is rendered.
///
/// Upstream-family codes classify by code first: their `status` is the
/// real vendor status (or a pinned 502), which must not be mistaken for
/// the client's own auth/validation failure.
pub fn classify(code: ErrCode, status: u16) -> Option<ErrClass> {
if status == 499 {
return None;
}
let class = match code {
ErrCode::FED_RESP_TIMEOUT => ErrClass::ModelTimeout,
ErrCode::FED_RESP_UNKNOWN
| ErrCode::FED_RESP_RPC_FAILED
| ErrCode::FED_RESP_NIL
| ErrCode::FED_RESP_STATUS_NOT_ZERO
| ErrCode::PARSE_FED_RESP
| ErrCode::GEN_RES_NOT_NULL
// a terminal upstream 429/503 keeps its transient retry
// semantics instead of collapsing into the 424 "don't retry"
| ErrCode::EMPTY_RESP => match status {
408 => ErrClass::ModelTimeout,
429 => ErrClass::Throttling,
503 => ErrClass::ServiceUnavailable,
_ => ErrClass::ModelError,
},
ErrCode::STOP_LIMIT_MSG => ErrClass::Throttling,
ErrCode::QUOTA_EXHAUSTED => ErrClass::ServiceQuotaExceeded,
ErrCode::PERMISSION_CHECK => ErrClass::AccessDenied,
ErrCode::REQ_JSON | ErrCode::REQ_NON_CHAT => ErrClass::Validation,
_ => return Self::from_status(status),
};
Some(class)
}

/// Classify by external-facing status alone (ad-hoc error sites and the
/// open `ErrCode` tail). `None` for 499.
pub fn from_status(status: u16) -> Option<ErrClass> {
let class = match status {
401 => ErrClass::UnrecognizedClient,
403 => ErrClass::AccessDenied,
404 => ErrClass::ResourceNotFound,
408 => ErrClass::ModelTimeout,
409 => ErrClass::Conflict,
413 => ErrClass::RequestEntityTooLarge,
424 => ErrClass::ModelError,
429 => ErrClass::Throttling,
499 => return None,
503 => ErrClass::ServiceUnavailable,
s if s >= 500 => ErrClass::InternalServer,
// 400, 405, 415, 422, 501 and the remaining 4xx tail: the request
// as sent cannot be served
_ => ErrClass::Validation,
};
Some(class)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn code_is_snake_of_name() {
for c in [
ErrClass::Validation,
ErrClass::ServiceQuotaExceeded,
ErrClass::UnrecognizedClient,
ErrClass::AccessDenied,
ErrClass::ResourceNotFound,
ErrClass::ModelTimeout,
ErrClass::Conflict,
ErrClass::RequestEntityTooLarge,
ErrClass::ModelError,
ErrClass::Throttling,
ErrClass::InternalServer,
ErrClass::ServiceUnavailable,
ErrClass::ModelStreamError,
] {
let mut snake = String::new();
for (i, ch) in c.name().chars().enumerate() {
if ch.is_uppercase() {
if i > 0 {
snake.push('_');
}
snake.extend(ch.to_lowercase());
} else {
snake.push(ch);
}
}
assert_eq!(c.code(), snake, "{}", c.name());
}
}

#[test]
fn upstream_family_classifies_by_code_not_status() {
// a vendor 401 is the upstream's failure, not the client's
let c = ErrClass::classify(ErrCode::FED_RESP_STATUS_NOT_ZERO, 401).unwrap();
assert_eq!(c, ErrClass::ModelError);
assert_eq!(c.status(), 424);
// terminal vendor throttle/capacity keep their transient semantics
let c = ErrClass::classify(ErrCode::FED_RESP_STATUS_NOT_ZERO, 429).unwrap();
assert_eq!(c, ErrClass::Throttling);
let c = ErrClass::classify(ErrCode::FED_RESP_STATUS_NOT_ZERO, 503).unwrap();
assert_eq!(c, ErrClass::ServiceUnavailable);
// deadline family
let c = ErrClass::classify(ErrCode::FED_RESP_TIMEOUT, 502).unwrap();
assert_eq!((c, c.status()), (ErrClass::ModelTimeout, 408));
let c = ErrClass::classify(ErrCode::QUOTA_EXHAUSTED, 400).unwrap();
assert_eq!(c, ErrClass::ServiceQuotaExceeded);
}

#[test]
fn client_closed_is_not_rendered() {
assert!(ErrClass::classify(ErrCode::SYSTEM_ERROR, 499).is_none());
assert!(ErrClass::from_status(499).is_none());
}

#[test]
fn own_faults_classify_by_status() {
assert_eq!(
ErrClass::classify(ErrCode::SYSTEM_ERROR, 503),
Some(ErrClass::ServiceUnavailable)
);
assert_eq!(
ErrClass::classify(ErrCode::SYSTEM_ERROR, 500),
Some(ErrClass::InternalServer)
);
assert_eq!(
ErrClass::classify(ErrCode::REQ_PARAM, 404),
Some(ErrClass::ResourceNotFound)
);
assert_eq!(
ErrClass::classify(ErrCode::REQ_PARAM, 400),
Some(ErrClass::Validation)
);
}
}
4 changes: 4 additions & 0 deletions crates/consts/src/error_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ impl ErrCode {
pub const FED_RESP_STATUS_NOT_ZERO: ErrCode = ErrCode(2008);
pub const PARSE_FED_RESP: ErrCode = ErrCode(2009);
pub const GEN_RES_NOT_NULL: ErrCode = ErrCode(2010);
/// Upstream deadline exceeded (header, body, or stream-idle); kept at an
/// internal 5xx status so failover still runs, rendered externally as 408.
pub const FED_RESP_TIMEOUT: ErrCode = ErrCode(2012);

pub const REQ_JSON: ErrCode = ErrCode(3001);
pub const REQ_PARAM: ErrCode = ErrCode(3002);
Expand All @@ -40,6 +43,7 @@ impl ErrCode {

pub const EMPTY_RESP: ErrCode = ErrCode(4003);
pub const STOP_LIMIT_MSG: ErrCode = ErrCode(4004);
pub const QUOTA_EXHAUSTED: ErrCode = ErrCode(4005);

#[inline]
pub const fn value(self) -> i64 {
Expand Down
2 changes: 2 additions & 0 deletions crates/consts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
//! Layer L0: depends on nothing internal. Holds the error-code model and the
//! `Protocol` enum the engine factory dispatches on.

pub mod error_class;
pub mod error_code;
pub mod protocol;

pub use error_class::ErrClass;
pub use error_code::ErrCode;
pub use protocol::Protocol;

Expand Down
26 changes: 20 additions & 6 deletions crates/dag/src/nodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ const DEFAULT_COMPLETION_RESERVE: i64 = 256;
/// `max_tokens: i64::MAX` can't overflow the estimate or corrupt the counter.
const MAX_RESERVE: i64 = 1_000_000;

/// A shared admission denial as the wire error every limit answers with.
/// The shared 429 for throttling-style denials (rate/QPM/TPM); hard quota
/// exhaustion answers with [`quota_denied`] instead.
fn limit_denied(msg: String) -> GatewayError {
GatewayError::new(ErrCode::STOP_LIMIT_MSG, 429, msg)
}

fn quota_denied(msg: String) -> GatewayError {
GatewayError::new(ErrCode::QUOTA_EXHAUSTED, 400, msg)
}

/// preprocess/model_quota: per-(AK, model) daily token cap — AK override, else
/// tenant default, else unmetered (the per-AK daily cap backstops). Over-quota
/// degrades to the tenant's fallback model when one is configured. Runs before
Expand Down Expand Up @@ -298,7 +303,7 @@ impl DagNode for QuotaCheck {
let at = gw_state::epoch_secs();
admission::reserve_daily(ctx.state.governance.as_ref(), &ctx.ak, est, at)
.await
.map_err(limit_denied)?;
.map_err(quota_denied)?;
ctx.quota_reserved = Some(est);
ctx.quota_at = at;
ctx.decide("quota_check", format!("reserved {est}"));
Expand Down Expand Up @@ -461,7 +466,7 @@ impl DagNode for UserBudgetGate {
ctx.effective_user_id(),
)
.await
.map_err(limit_denied)
.map_err(quota_denied)
}
}

Expand Down Expand Up @@ -534,7 +539,7 @@ impl DagNode for CallEngine {
ctx.state
.avail
.record(requested_model(ctx.request.model_param_v2.as_ref()), false);
return Err(first_err);
return Err(named(first_err, ctx));
};
let spillover = failed.is_ptu() && !next.is_ptu();
ctx.decide(
Expand Down Expand Up @@ -564,15 +569,24 @@ impl DagNode for CallEngine {
.avail
.record(requested_model(ctx.request.model_param_v2.as_ref()), false);
note_failure(ctx, &next.name, threshold, cooldown).await;
Err(e)
Err(named(e, ctx))
}
}
}
Err(e) => Err(e),
Err(e) => Err(named(e, ctx)),
}
}
}

/// Attach the requested model to a terminal engine-call error, for the
/// contract's 424 `resource_name` extra. Error path only.
fn named(mut e: GatewayError, ctx: &DagContext) -> GatewayError {
if e.resource.is_none() {
e.resource = Some(requested_model(ctx.request.model_param_v2.as_ref()).to_owned());
}
e
}

/// Record an account failure; on the cooldown transition, alert and note the
/// decision — one implementation for both engine attempts, so the fleet-backed
/// `record_failure` call and its alerting can't drift apart.
Expand Down
Loading