mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-11 10:23:17 +08:00
* Add `args` field in addition to `command`. * Implement logic in atelet to resolve both argv from the actor template fields and the OCI image config (`entrypoint` and `cmd` fields). * Update demos to not hardcode command as now we honor the image default, which makes it not necessary. Fixes #456
130 lines
5.1 KiB
Go
130 lines
5.1 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package ateerrors
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"slices"
|
|
|
|
epb "google.golang.org/genproto/googleapis/rpc/errdetails"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// errorDomain is the AIP-193 ErrorInfo.errorDomain (https://google.aip.dev/193) stamped
|
|
// into every error built by NewGRPCError, identifying Agent Substrate as the
|
|
// source service.
|
|
const errorDomain = "substrate.dev"
|
|
|
|
// Reason is the AIP-193 ErrorInfo.Reason: a bounded, UPPER_SNAKE_CASE enum of
|
|
// failure causes the control plane can classify on. A Reason is also an error:
|
|
// source layers tag failures with fmt.Errorf("%w: ...", ReasonX, err), and
|
|
// each RPC boundary claims the Reasons it treats as terminal (CrashIfReason);
|
|
// untagged errors stay retriable.
|
|
type Reason string
|
|
|
|
// Error makes a Reason wrappable with %w and matchable with errors.Is/As.
|
|
func (r Reason) Error() string { return string(r) }
|
|
|
|
const (
|
|
ReasonTerminalFileSystemError Reason = "TERMINAL_FILE_SYSTEM_ERROR"
|
|
ReasonInvalidSandboxAsset Reason = "INVALID_SANDBOX_ASSET"
|
|
ReasonInvalidCheckpointResult Reason = "INVALID_CHECKPOINT_RESULT"
|
|
ReasonFaileSaveSnapshot Reason = "FAILED_SAVE_SNAPSHOT"
|
|
ReasonInvalidObjectURL Reason = "INVALID_OBJECT_URL"
|
|
ReasonFailedGetExternalObject Reason = "FAILED_GET_EXTERNAL_OBJECT"
|
|
// ReasonInvalidContainerConfig marks a container whose configuration cannot
|
|
// produce a runnable process (e.g. the resolved argv is empty because the
|
|
// image defines no ENTRYPOINT/CMD and the ActorTemplate sets no command/args).
|
|
ReasonInvalidContainerConfig Reason = "INVALID_CONTAINER_CONFIG"
|
|
)
|
|
|
|
// MetadataKeyActorCrashed marks (in ErrorInfo.Metadata) a failure that requires
|
|
// the control plane to crash the actor.
|
|
const MetadataKeyActorCrashed = "actorCrashed"
|
|
|
|
// ActorCrashedMetadata returns the AIP-193 metadata marking a failure as
|
|
// requiring the actor to be crashed. The control plane reads it via
|
|
// ActorCrashRequested.
|
|
func ActorCrashedMetadata() map[string]string {
|
|
return map[string]string{MetadataKeyActorCrashed: "true"}
|
|
}
|
|
|
|
// NewGRPCError builds an internal gRPC status error per AIP-193
|
|
// (https://google.aip.dev/193#status-message), with a google.rpc.ErrorInfo detail
|
|
// carrying the given Reason ("UNSET" when empty).
|
|
// metadata carries additional structured directives such as ActorCrashedMetadata(),
|
|
// which the control plane reads via ActorCrashRequested to decide whether to crash
|
|
// the actor.
|
|
func NewGRPCError(ctx context.Context, grpcCode codes.Code, reason Reason, metadata map[string]string, err error) error {
|
|
// Validate the input parameters.
|
|
if err == nil || grpcCode == codes.OK {
|
|
return fmt.Errorf("cannot use NewGRPCError with OK error code or a nil err grpcCode=%v, err=%w. Return nil instead", grpcCode, err)
|
|
}
|
|
if reason == "" {
|
|
reason = "UNSET"
|
|
}
|
|
st, derr := status.New(grpcCode, err.Error()).WithDetails(
|
|
&epb.ErrorInfo{
|
|
Domain: errorDomain,
|
|
Reason: string(reason),
|
|
Metadata: metadata,
|
|
},
|
|
)
|
|
if derr != nil {
|
|
// WithDetails on *epb.ErrorInfo should never fail; but if it ever does, the
|
|
// reason and metadata are lost and the control plane will misclassify the
|
|
// failure (e.g. a real crash read as a transient error). Log loudly for
|
|
// debugging purpose.
|
|
slog.ErrorContext(ctx, "ateerrors: failed to attach ErrorInfo to gRPC status; adding Reason/metadata to the error message instead",
|
|
"err", derr, "reason", reason, "metadata", metadata, "code", grpcCode)
|
|
return status.Error(grpcCode, fmt.Errorf("reason:%s metadata:%v, error %w", reason, metadata, err).Error())
|
|
}
|
|
return st.Err()
|
|
}
|
|
|
|
// CrashIfReason sets the err to a DataLoss gRPC status with the actor-crash
|
|
// directive iff its chain carries one of the given Reasons; any other error is
|
|
// returned unchanged. Claiming is per call site: the same tagged failure may
|
|
// crash the actor in one RPC and stay retriable in another.
|
|
func CrashIfReason(ctx context.Context, err error, reasons ...Reason) error {
|
|
r, ok := errors.AsType[Reason](err)
|
|
if !ok || !slices.Contains(reasons, r) {
|
|
return err
|
|
}
|
|
return NewGRPCError(ctx, codes.DataLoss, r, ActorCrashedMetadata(), err)
|
|
}
|
|
|
|
// ActorCrashRequested reports whether any ErrorInfo carried by err has the
|
|
// actorCrashed=true directive, i.e. the failure requires the control plane to
|
|
// crash the actor.
|
|
func ActorCrashRequested(err error) bool {
|
|
st, ok := status.FromError(err)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, d := range st.Details() {
|
|
if info, ok := d.(*epb.ErrorInfo); ok {
|
|
if info.GetMetadata()[MetadataKeyActorCrashed] == "true" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|