11년 묵은 AWS issue 기여기 (2) - 아무도 검증하지 않던 WaiterConfig
Published:
(1)편에서 AWS CLI의 wait 명령어에 --delay, --max-attempts 옵션을 추가한 이야기를 했다.
PR을 올리면서 한 가지 의식적으로 넣지 않은 게 있었다. 값 검증이다. --max-results -1이나 --page-size 0 같은 다른 CLI 인자들도 CLI 레이어에서 음수를 따로 거부하지 않는다. botocore의 ParamValidator가 타입과 범위를 체크하고, 그래도 빠지면 AWS 서버가 InvalidParameterValue로 잡아준다. 그게 이 프로젝트의 convention이라고 생각했고, --delay와 --max-attempts도 botocore나 AWS가 알아서 처리할 거라 판단해서 검증을 생략했다.
그런데 PR을 올리고 나서 --delay -1을 넣어봤다.
Part 1. --delay -1을 넣으면?
$ aws s3api wait bucket-exists --bucket test --delay -1
sleep length must be non-negative
Python time.sleep(-1)이 그대로 터진다. CLI도, botocore도, AWS 서버도 — 아무도 이 값을 검증하지 않고 있었다. sleep length must be non-negative라는 메시지는 Python 런타임이 뱉은 것이다. AWS CLI의 에러 메시지가 아니다.
다른 케이스도 돌려봤다.
| 입력 | 결과 | 뭐가 문제인가 |
|---|---|---|
--delay -1 | ValueError: sleep length must be non-negative | Python 런타임 에러. CLI 에러가 아님 |
--delay 0 | sleep 없이 API 연속 호출 | 에러 없음. 하지만 API throttling 위험 |
--max-attempts 0 | 1번 폴링 후 즉시 Max attempts exceeded | 의도한 동작이 아닐 것 |
--max-attempts -1 | 1번 폴링 후 즉시 Max attempts exceeded | 1 >= -1이 true라서 |
--delay -1만 Python 런타임에서 터지고, 나머지는 “동작은 하는데 이상한” 상태다. 어떤 경우든 CLI가 친절한 에러 메시지를 주지 않는다.
--delay -1이 왜 터지기도 하고 안 터지기도 하나
재밌는 건, --delay -1 --max-attempts 1로 넣으면 안 터진다.
$ aws s3api wait bucket-exists --bucket test --delay -1 --max-attempts 1
Waiter BucketExists failed: Max attempts exceeded
time.sleep(-1)까지 도달하지 않고 Max attempts exceeded가 먼저 나온다. 폴링 루프의 순서 때문이다.
while True:
response = self._operation_method(**kwargs) # 1. API 호출
num_attempts += 1 # 2. 카운트 증가
for acceptor in acceptors: # 3. 상태 확인
if acceptor.matcher_func(response):
current_state = acceptor.state
break
if current_state == 'success': # 4. 성공이면 리턴
return
if current_state == 'failure': # 5. 실패면 에러
raise WaiterError(...)
if num_attempts >= max_attempts: # 6. max 도달이면 에러
raise WaiterError(...) # ← MaxAttempts=1이면 여기서 끝
time.sleep(sleep_amount) # 7. sleep
# ← Delay=-1이면 여기서 터짐
MaxAttempts: 1이면 첫 폴링 후 6번(1 >= 1)에서 바로 WaiterError가 나와서 7번 time.sleep(-1)까지 도달하지 않는다. MaxAttempts: 2 이상이어야 ValueError가 발생한다. 즉 같은 잘못된 입력인데 다른 인자 값에 따라 터지기도 하고 안 터지기도 한다. 이건 유효하지 않은 입력이 검증 없이 폴링 루프 안으로 들어왔기 때문에 생기는 비결정적 동작이다.
Part 2. 다른 인자들은 어떻게 하고 있나
--delay와 --max-attempts만 이런 건지, 다른 인자들도 그런 건지 궁금해졌다. aws ec2 wait instance-running에 달린 14개 인자를 전부 나열해봤다.
--cli-input-json string CliInputJSONArgument
--cli-input-yaml string CliInputYAMLArgument
--delay integer WaiterArgument ← PR #10224에서 추가
--dry-run boolean BooleanArgument
--filters list ListArgument
--generate-cli-skeleton string GenerateCliSkeletonArgument
--instance-ids list ListArgument
--max-attempts integer WaiterArgument ← PR #10224에서 추가
--max-items integer PageArgument
--max-results integer CLIArgument
--next-token string CLIArgument
--no-dry-run boolean BooleanArgument
--page-size integer PageArgument
--starting-token string PageArgument
이 인자들은 출처에 따라 4종류로 나뉜다.
- API 파라미터 (
CLIArgument,ListArgument,BooleanArgument):DescribeInstancesAPI의 input shape에서 자동 생성.waiter.wait(**kwargs)를 통해 API 호출 시 그대로 전달됨. - 페이지네이션 (
PageArgument): CLI가 자동 추가.kwargs.pop('PaginationConfig')으로 빠져서 botocore paginator가 로컬 소비. - CLI 공통 (
CliInputJSONArgument,GenerateCliSkeletonArgument): 모든 커맨드에 붙는 유틸리티. CLI 레이어에서 소비. - Waiter 전용 (
WaiterArgument): PR #10224에서 추가.kwargs.pop('WaiterConfig')으로 빠져서 botocore waiter가 로컬 소비.
출처가 다르면 검증 경로도 완전히 다르다. 14개 인자 전부에 잘못된 값을 넣어보기로 했다.
3개 레이어 검증 구조
AWS CLI의 인자 값이 실제 API 호출까지 도달하려면 3개 레이어를 거친다.
사용자 입력
│
▼
[1] AWS CLI ─── argparse type 캐스팅 + 자체 검증
│ (awscli/arguments.py, awscli/customizations/*.py)
│
▼
[2] botocore ── ParamValidator (타입/범위/필수 필드/알 수 없는 파라미터)
│ (botocore/validate.py → ParamValidationDecorator.serialize_to_request)
│
▼
[3] AWS API 서버 ── 값 유효성, 비즈니스 로직
API 파라미터는 3개 레이어를 전부 거친다.
# [1] CLI: type=str 캐스팅 (CLIArgument.TYPE_MAP에서 'integer': str)
# [2] botocore ParamValidator: 타입 체크 + 범위 체크
ec2.describe_instances(InstanceIds=[-1])
→ ParamValidationError: Invalid type for parameter InstanceIds[0],
value: -1, type: <class 'int'>, valid types: <class 'str'>
ec2.describe_instances(MaxResults='abc')
→ ParamValidationError: Invalid type for parameter MaxResults,
value: abc, type: <class 'str'>, valid types: <class 'int'>
# [3] AWS 서버: 값 유효성
ec2.describe_instances(MaxResults=-1)
→ ClientError: InvalidParameterValue: Value ( -1 ) is invalid.
Expecting a value greater than 5.
ec2.describe_instances(InstanceIds=['invalid-format'])
→ ClientError: InvalidInstanceID.Malformed: Invalid id: "invalid-format"
botocore의 ParamValidator(botocore/validate.py:181)는 API operation의 input shape을 기준으로 검증한다. 타입이 틀리면 ParamValidationError, API shape에 없는 키면 Unknown parameter 에러다.
# botocore/validate.py - ParamValidationDecorator
def serialize_to_request(self, parameters, operation_model):
input_shape = operation_model.input_shape
if input_shape is not None:
report = self._param_validator.validate(
parameters, operation_model.input_shape # ← API input shape 기준
)
if report.has_errors():
raise ParamValidationError(report=report.generate_report())
그런데 WaiterConfig는 이 검증을 거치지 않는다. (1)편에서 봤듯이, Waiter.wait() 안에서 kwargs.pop('WaiterConfig', {})으로 빼낸 뒤 로컬에서 소비한다. pop된 후의 kwargs가 self._operation_method(**kwargs)(= API 호출)로 전달되므로, ParamValidator는 이미 빠진 WaiterConfig를 검증할 수 없다.
같은 이유로 AWS 서버에도 전송되지 않는다. 서버는 DescribeInstances의 API 파라미터만 받을 뿐, WaiterConfig는 본 적이 없다.
PaginationConfig도 같은 패턴이다. botocore/paginate.py에서 kwargs.pop('PaginationConfig', {})으로 빼낸 뒤 로컬에서 소비한다.
전체 매트릭스
14개 인자를 전부 돌려본 결과를 정리했다.
| 인자 | [1] CLI | [2] botocore | [3] AWS 서버 | 검증 안 되는 입력 |
|---|---|---|---|---|
--cli-input-json | JSON 파싱 ✅ | - | - | - |
--cli-input-yaml | YAML 파싱 ✅ | - | - | - |
--generate-cli-skeleton | choices ✅ | - | - | - |
--dry-run / --no-dry-run | flag ✅ | 타입 ✅ | 권한 ✅ | - |
--instance-ids | type=str | 타입 ✅ | 형식 ✅ | - |
--filters | type=str | 타입+구조 ✅ | 이름 ✅ | - |
--max-results | type=str | 타입 ✅ | 범위 ✅ | - |
--next-token | type=str | 타입 ✅ | 유효성 ✅ | - |
--max-items | warning ⚠️ | int 캐스팅 | 안 감 | 음수/0 (warning만) |
--page-size | type=int | int 캐스팅 | 범위 ✅ | - |
--starting-token | type=str | 없음 | 유효성 ✅ | int → AttributeError |
--delay | type=int만 | 없음 | 안 감 | 음수, 0 |
--max-attempts | type=int만 | 없음 | 안 감 | 음수, 0 |
--delay와 --max-attempts만 argparse에서 type=int 캐스팅만 하고, 그 이후 어떤 레이어에서도 값을 검증하지 않는다.
비교해보면, 비슷한 포지션의 --max-items(PageArgument)은 CLI 레이어에서 <= 0이면 warning을 출력한다.
# awscli/customizations/paginate.py - PageArgument.add_to_params
def add_to_params(self, parameters, value):
if value is not None:
if self._serialized_name == 'MaxItems' and int(value) <= 0:
self._emit_non_positive_max_items_warning() # ← warning 출력
pagination_config = parameters.get('PaginationConfig', {})
pagination_config[self._serialized_name] = value
WaiterArgument.add_to_params에는 이런 체크가 전혀 없었다.
재밌는 비대칭
waiter JSON 파일의 기본값은 테스트에서 스키마 검증을 한다.
# botocore/tests/functional/test_waiter_config.py
WAITER_SCHEMA = {
"properties": {
"waiters": {
"additionalProperties": {
"properties": {
"delay": {
"type": "number",
"minimum": 0, # ← delay >= 0
},
"maxAttempts": {
"type": "integer",
"minimum": 1 # ← maxAttempts >= 1
},
},
"required": ["operation", "delay", "maxAttempts", "acceptors"],
}
}
}
}
waiter JSON 파일에 delay: -1을 넣으면 이 스키마 검증에서 걸린다. 그런데 런타임에 사용자가 WaiterConfig로 넘기는 값에는 같은 검증이 적용되지 않는다. 같은 의미의 값인데, 한쪽은 검증하고 한쪽은 안 하는 비대칭이다.
데이터 흐름 요약
CLI: --delay -1 --max-attempts 0
│
▼
argparse (awscli/customizations/waiters.py)
type=int → -1, 0 통과 (정수이므로)
│
▼
WaiterArgument.add_to_params (waiters.py)
→ parameters['WaiterConfig'] = {'Delay': -1, 'MaxAttempts': 0}
→ 값 검증 없음
│
▼
botocore Waiter.wait (botocore/waiter.py)
config = kwargs.pop('WaiterConfig', {}) # pop
sleep_amount = config.get('Delay', ...) # → -1 (검증 없음)
max_attempts = config.get('MaxAttempts', ...) # → 0 (검증 없음)
│
├─ kwargs (WaiterConfig 제외) → self._operation_method(**kwargs)
│ → ParamValidator 검증 → AWS API 호출
│ (여기서는 API 파라미터만 검증됨)
│
└─ sleep_amount, max_attempts → 로컬 소비
→ time.sleep(-1) → ValueError (Python 런타임 에러)
→ 1 >= 0 → WaiterError ("Max attempts exceeded" — 원인 불명확)
kwargs.pop()으로 빠지는 순간, 그 값은 어떤 검증 레이어도 거치지 않게 된다. API 파라미터는 pop되지 않으니까 ParamValidator와 AWS 서버가 잡아주지만, WaiterConfig와 PaginationConfig는 pop된 후 클라이언트 로컬에서 소비되므로 스스로 검증하지 않으면 아무도 하지 않는다.
Part 3. 두 개의 수정
검증이 필요한 곳이 두 군데다. CLI 레이어와 botocore 레이어. CLI 레이어만 고치면 boto3 직접 사용자는 여전히 노출되고, botocore 레이어만 고치면 CLI에서 불친절한 에러가 나온다.
aws-cli: CLI 레이어 검증
먼저 내가 추가한 --delay, --max-attempts 인자의 값 검증을 aws-cli에 넣었다. WaiterArgument.add_to_params에서 값을 WaiterConfig에 넣기 전에 체크한다.
# awscli/customizations/waiters.py
from awscli.customizations.exceptions import ParamValidationError
class WaiterArgument(BaseCLIArgument):
def add_to_params(self, parameters, value):
if value is not None:
if self._serialized_name == 'Delay' and value < 0:
raise ParamValidationError(
'--delay must be a non-negative integer, '
'got %s' % value
)
if self._serialized_name == 'MaxAttempts' and value < 1:
raise ParamValidationError(
'--max-attempts must be a positive integer, '
'got %s' % value
)
waiter_config = parameters.get('WaiterConfig', {})
waiter_config[self._serialized_name] = value
parameters['WaiterConfig'] = waiter_config
ParamValidationError는 aws-cli가 파라미터 에러에 쓰는 기존 예외다. --cli-input-json에 잘못된 JSON을 넣을 때도 이 예외가 나온다. 기존 패턴과 일관성을 맞췄다.
이제 잘못된 값을 넣으면 명확한 에러가 나온다.
$ aws ec2 wait instance-running --instance-ids i-123 --delay -1
aws: [ERROR]: An error occurred (ParamValidation):
--delay must be a non-negative integer, got -1
$ aws ec2 wait instance-running --instance-ids i-123 --max-attempts 0
aws: [ERROR]: An error occurred (ParamValidation):
--max-attempts must be a positive integer, got 0
다른 인자들이 비양수 값을 CLI 레이어에서 검증하지 않는데 왜 여기서는 하냐면, 다른 인자들은 botocore ParamValidator나 AWS 서버가 잡아주지만 WaiterConfig는 kwargs.pop()으로 빠지기 때문에 아무도 잡아주지 않기 때문이다.
테스트도 추가했다. 기존 24개 + 7개 = 31개.
python -m pytest tests/unit/customizations/test_waiters.py -q
# 31 passed
botocore: 근본 수정
하지만 CLI 레이어 검증만으로는 부족하다. boto3로 직접 waiter.wait()를 호출하는 사용자는 여전히 노출된다.
# CLI를 안 쓰고 boto3를 직접 쓰면?
waiter.wait(Bucket='my-bucket', WaiterConfig={'Delay': -1, 'MaxAttempts': 2})
# → ValueError: sleep length must be non-negative (여전히 Python 런타임 에러)
그래서 botocore에 이슈를 올리고(boto/botocore#3674) 수정을 구현했다. Waiter.wait() 내부에서 폴링 루프 진입 전에 검증한다.
# botocore/waiter.py
class Waiter:
@staticmethod
def _validate_waiter_config(sleep_amount, max_attempts):
if (
isinstance(sleep_amount, bool)
or not isinstance(sleep_amount, (int, float))
or sleep_amount < 0
):
raise WaiterConfigError(
error_msg=(
f'Invalid value for WaiterConfig option Delay: '
f'{sleep_amount!r}. Expected a non-negative number.'
)
)
if (
isinstance(max_attempts, bool)
or not isinstance(max_attempts, int)
or max_attempts < 1
):
raise WaiterConfigError(
error_msg=(
f'Invalid value for WaiterConfig option MaxAttempts: '
f'{max_attempts!r}. Expected a positive integer.'
)
)
def wait(self, **kwargs):
# ...
config = kwargs.pop('WaiterConfig', {})
sleep_amount = config.get('Delay', self.config.delay)
max_attempts = config.get('MaxAttempts', self.config.max_attempts)
self._validate_waiter_config(sleep_amount, max_attempts) # ← 추가
# ... 폴링 루프 ...
몇 가지 설계 결정이 있다.
WaiterConfigError를 쓴 이유. botocore에는 이미 WaiterConfigError가 정의되어 있다. waiter JSON 파일의 버전이 틀리거나 알 수 없는 matcher 타입이 나올 때 쓰인다. 같은 “waiter 설정이 잘못됐다” 범주이므로 새 예외를 만들 필요가 없었다.
isinstance(sleep_amount, bool) 체크가 먼저 오는 이유. Python에서 bool은 int의 서브클래스다. isinstance(True, int)이 True를 반환한다. bool 체크 없이 isinstance(sleep_amount, (int, float))만 하면 Delay: True가 sleep_amount = 1로 통과한다. 의도치 않은 타입이므로 조기에 거부하는 게 맞다.
# Python에서 bool은 int의 서브클래스
isinstance(True, int) # True ← 이게 문제
isinstance(True, bool) # True
True + True # 2
@staticmethod으로 분리한 이유. 검증 로직이 self에 의존하지 않고, 단독으로 테스트할 수 있어야 하므로.
검증 규칙의 근거. waiter JSON 스키마와 일치시켰다.
| 파라미터 | 규칙 | 근거 |
|---|---|---|
Delay | >= 0, int 또는 float | time.sleep()이 non-negative number를 받음. 스키마: "minimum": 0 |
MaxAttempts | >= 1, int만 | 폴링 횟수는 양의 정수. 스키마: "minimum": 1 |
Delay: 0은 허용한다. 스키마에서도 "minimum": 0이고, time.sleep(0)은 유효하다. sleep 없이 연속 호출하면 API throttling 위험이 있지만, 그건 사용자의 의도적 선택일 수 있다. 값을 거부하기보다는 문서화로 해결할 문제다.
테스트는 11개 추가했다.
| 테스트 | 검증 내용 |
|---|---|
test_waiter_config_rejects_negative_delay | Delay: -1 → WaiterConfigError |
test_waiter_config_rejects_non_numeric_delay | Delay: 'fast' → WaiterConfigError |
test_waiter_config_rejects_none_delay | Delay: None → WaiterConfigError |
test_waiter_config_rejects_boolean_delay | Delay: True → WaiterConfigError |
test_waiter_config_rejects_zero_max_attempts | MaxAttempts: 0 → WaiterConfigError |
test_waiter_config_rejects_negative_max_attempts | MaxAttempts: -1 → WaiterConfigError |
test_waiter_config_rejects_non_integer_max_attempts | MaxAttempts: 'abc' → WaiterConfigError |
test_waiter_config_rejects_boolean_max_attempts | MaxAttempts: True → WaiterConfigError |
test_waiter_config_rejects_float_max_attempts | MaxAttempts: 1.5 → WaiterConfigError |
test_waiter_config_accepts_valid_values | Delay: 0, MaxAttempts: 1 → 정상 |
test_waiter_config_accepts_float_delay | Delay: 0.5 → 정상 |
모든 거부 테스트에서 operation_method.assert_not_called()를 확인한다. 검증 실패 시 API 호출이 한 번도 일어나지 않는 것을 보장하기 위해서다.
python -m pytest tests/unit/test_waiters.py -q
# 53 passed
데이터 흐름 (수정 후)
CLI: --delay -1 --max-attempts 0
│
▼
[1] aws-cli WaiterArgument.add_to_params
→ --delay < 0 검사
→ ParamValidationError: "--delay must be a non-negative integer, got -1"
→ 여기서 끝. botocore까지 안 감.
boto3 직접 호출: WaiterConfig={'Delay': -1, 'MaxAttempts': 2}
│
▼
[2] botocore Waiter.wait()
→ config = kwargs.pop('WaiterConfig', {})
→ sleep_amount = -1
→ _validate_waiter_config(-1, 2)
→ WaiterConfigError: "Invalid value for WaiterConfig option Delay: -1.
Expected a non-negative number."
→ 여기서 끝. API 호출 안 함.
정상 입력: --delay 10 --max-attempts 100
│
▼
[1] aws-cli: 10 >= 0, 100 >= 1 → 통과
│
▼
[2] botocore: _validate_waiter_config(10, 100) → 통과
│
▼
time.sleep(10) # 유효한 값만 도달
num_attempts >= 100 # 유효한 값만 도달
느낀 점
기능 추가에서 끝내지 말고 엣지 케이스를 돌려봐야 한다. --delay -1을 넣어보지 않았으면 검증 갭을 못 찾았을 것이다. “다른 인자들은 어떻게 하고 있지?”라는 질문이 14개 인자 전수 조사로 이어졌고, WaiterConfig만 3개 레이어 어디에서도 검증되지 않는다는 구조적 문제를 발견했다. 근본 원인은 kwargs.pop()으로 빠지는 값이 기존 검증 파이프라인을 우회하는 것이었다.
하나의 PR이 다음 기여로 이어진다. aws-cli에 인자를 추가하다가 botocore의 검증 갭을 발견했고, 그게 별도 이슈와 PR로 이어졌다. 코드를 깊이 읽으면 관련 문제가 자연스럽게 보인다.
다음 기여는 어디로
솔직히 써두자면 — aws-cli 리뷰가 너무 느리다. 오픈소스라고 하지만 체감은 closed source에 가깝다. 간단한 수정 하나에 리뷰 받는 데 한 달 이상 걸리고, 이번처럼 레이어가 여럿 얽힌 PR은 아예 묻혀버릴 수도 있겠다는 느낌이 든다. 내부 팀의 우선순위가 외부 PR보다 앞서는 건 이해가 가는데, 기여자 입장에서는 “내가 지금 하고 있는 게 맞는 방향인지”에 대한 피드백이 너무 늦게 온다.
그래서 다음부터는 Apache 계열 프로젝트를 보려고 한다. 커뮤니티 중심으로 돌아가는 구조라 리뷰 사이클이 훨씬 빠르고, 컨벤션과 설계 논의가 메일링 리스트·JIRA에 공개돼 있어서 “내가 맞게 하고 있나”를 비교할 기준이 많다. 후보는 둘 — Kafka와 Airflow. 어느 쪽이든 내가 이미 프로덕션에서 써본 코드라 문제가 보이기 쉽고, 커뮤니티 리뷰 밀도도 높아서 한 번의 PR에서 배우는 양이 다를 것 같다.
AWS CLI에서 얻은 건 “보이지 않는 검증 갭을 찾는 감각” 이다. 이 감각을 Apache 프로젝트에서 써보면 어떻게 되는지, 다음 글에서 이어가 볼 생각이다.
PR: aws/aws-cli#10224 Issue: boto/botocore#3674
