Add HTTP upload idempotency support

This commit is contained in:
2026-06-04 14:03:26 +00:00
parent 1a402e6cfa
commit a15722571f
9 changed files with 603 additions and 36 deletions

View File

@@ -29,9 +29,12 @@ type uploadAcceptedResponse struct {
}
type httpErrorResponse struct {
Error string `json:"error"`
Error string `json:"error"`
Retryable bool `json:"retryable,omitempty"`
}
const idempotencyKeyHeader = "Idempotency-Key"
func newUploadHTTPHandler(ctx context.Context, cfg config.Config, environment config.Environment) (http.Handler, error) {
config.ApplyDefaults(&cfg)
tokens, err := resolveUploadTokens(cfg, environment)
@@ -98,14 +101,16 @@ func (handler uploadHTTPHandler) handleUpload(w http.ResponseWriter, r *http.Req
writeHTTPError(w, http.StatusUnsupportedMediaType, "unsupported content type")
return
}
if !handler.coordinator.CanAccept() {
writeHTTPError(w, http.StatusServiceUnavailable, "upload queue is full")
idempotencyKey, err := uploadIdempotencyKey(r.Header)
if err != nil {
writeHTTPError(w, http.StatusBadRequest, "invalid idempotency key")
return
}
record, err := handler.coordinator.Submit(r.Context(), UploadRequest{
PipelineID: pipelineID,
ContentType: contentType,
Body: r.Body,
PipelineID: pipelineID,
ContentType: contentType,
Body: r.Body,
IdempotencyKey: idempotencyKey,
})
if err != nil {
writeUploadSubmitError(w, err)
@@ -144,10 +149,44 @@ func (handler uploadHTTPHandler) authenticate(header string) (string, bool) {
return pipelineID, ok
}
func uploadIdempotencyKey(header http.Header) (string, error) {
values := header.Values(idempotencyKeyHeader)
if len(values) == 0 {
return "", nil
}
if len(values) != 1 {
return "", fmt.Errorf("idempotency key must appear at most once")
}
key := values[0]
if key == "" {
return "", fmt.Errorf("idempotency key is required when header is present")
}
if len(key) > 128 {
return "", fmt.Errorf("idempotency key must be at most 128 bytes")
}
for index := 0; index < len(key); index++ {
character := key[index]
if character >= 'a' && character <= 'z' ||
character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' ||
character == '.' ||
character == '_' ||
character == '-' ||
character == ':' {
continue
}
return "", fmt.Errorf("idempotency key contains unsupported character")
}
return key, nil
}
func writeUploadSubmitError(w http.ResponseWriter, err error) {
var idempotencyConflict UploadIdempotencyConflictError
switch {
case IsUploadQueueFull(err):
writeHTTPError(w, http.StatusServiceUnavailable, "upload queue is full")
case errors.As(err, &idempotencyConflict):
writeHTTPErrorRetryable(w, http.StatusConflict, idempotencyConflict.Error(), idempotencyConflict.Retryable)
case errors.Is(err, ingest.ErrUploadTooLarge):
writeHTTPError(w, http.StatusRequestEntityTooLarge, "upload exceeds maximum size")
case errors.Is(err, ingest.ErrUnsupportedContentType):
@@ -158,7 +197,11 @@ func writeUploadSubmitError(w http.ResponseWriter, err error) {
}
func writeHTTPError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, httpErrorResponse{Error: message})
writeHTTPErrorRetryable(w, status, message, false)
}
func writeHTTPErrorRetryable(w http.ResponseWriter, status int, message string, retryable bool) {
writeJSON(w, status, httpErrorResponse{Error: message, Retryable: retryable})
}
func writeJSON(w http.ResponseWriter, status int, value any) {