| 3461 | |
| 3462 | |
| 3463 | class APIHandler(BaseHTTPRequestHandler): |
| 3464 | protocol_version = "HTTP/1.1" |
| 3465 | timeout = 30 # per socket OPERATION. On its own this does not stop a slowloris: |
| 3466 | # it restarts on every byte received, so a drip renews it forever. |
| 3467 | # READ_DEADLINE below is the cumulative bound that actually does. |
| 3468 | READ_DEADLINE = _positive_env("COLI_READ_DEADLINE", 30) # accept -> request read |
| 3469 | server_version = "colibri" |
| 3470 | _committed = False # status line already on the wire; reset per request below |
| 3471 | _body_read = False # request body fully consumed, so nothing is left to drain |
| 3472 | |
| 3473 | def setup(self): |
| 3474 | super().setup() |
| 3475 | # Keep the socket-backed reader; handle_one_request re-wraps it with a |
| 3476 | # fresh deadline per request rather than wrapping a wrapper each time. |
| 3477 | self._raw_rfile = self.rfile |
| 3478 | |
| 3479 | def log_message(self, fmt, *args): |
| 3480 | sys.stderr.write("[api] %s - %s\n" % (self.address_string(), fmt % args)) |
| 3481 | |
| 3482 | def handle_one_request(self): |
| 3483 | """Per-request bookkeeping for HTTP/1.1 persistence (#597 item 3). |
| 3484 | |
| 3485 | One handler instance serves every request on a keep-alive connection, so both flags |
| 3486 | reset here rather than in do_POST. The drain afterwards is the whole fix for the |
| 3487 | reported `Bad request syntax ('{...json...}POST /v1/...')`: any early rejection -- |
| 3488 | 403 Host, 401 auth, a bad or oversized Content-Length -- returns before read_json(), |
| 3489 | leaving the body in the socket, where the next readline() eats it as a request line. |
| 3490 | Draining once at the request boundary covers every such path, present and future, |
| 3491 | instead of asking each early return to remember.""" |
| 3492 | self._committed = False |
| 3493 | self._body_read = False |
| 3494 | # Fresh budget per request: a keep-alive connection may serve many, and |
| 3495 | # each is entitled to its own read window -- but none may drip forever. |
| 3496 | self.rfile = _DeadlineReader(self._raw_rfile, self.connection, |
| 3497 | self.timeout, self.READ_DEADLINE) |
| 3498 | try: |
| 3499 | super().handle_one_request() |
| 3500 | except TimeoutError: |
| 3501 | # The read budget ran out. Say so and close; do not answer, because |
| 3502 | # we never received a complete request to answer. |
| 3503 | sys.stderr.write("[api] %s - request read deadline exceeded\n" |
| 3504 | % self.address_string()) |
| 3505 | self.close_connection = True |
| 3506 | return |
| 3507 | except ConnectionError: |
| 3508 | # ConnectionError, not (BrokenPipeError, ConnectionResetError): those two |
| 3509 | # are SIBLINGS of ConnectionAbortedError under it, so the pair caught the |
| 3510 | # POSIX spellings and let the Windows one through. #854's log is pages of |
| 3511 | # `ConnectionAbortedError: [WinError 10053] An established connection was |
| 3512 | # aborted by the software in your host machine` escaping to socketserver, |
| 3513 | # from a `coli web` start that was otherwise healthy. |
| 3514 | # |
| 3515 | # The client hung up mid-response. That is not an error here, it is |
| 3516 | # how HTTP clients behave: `coli chat` polls /health while the model |
| 3517 | # loads and drops each connection as soon as it has its answer, and |
| 3518 | # Ctrl-C during a stream closes the socket by design -- the banner |
| 3519 | # tells the user to do exactly that. Without this, socketserver's |
| 3520 | # handler prints a full traceback per occurrence, so a normal start |
nothing calls this directly
no test coverage detected